Updated documents
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Audience:** AI assistants and developers working on this codebase.
|
> **Audience:** AI assistants and developers working on this codebase.
|
||||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
> **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)
|
12. [Audit Trail](#12-audit-trail)
|
||||||
13. [PDF Export](#13-pdf-export)
|
13. [PDF Export](#13-pdf-export)
|
||||||
14. [Scheduled Reports](#14-scheduled-reports)
|
14. [Scheduled Reports](#14-scheduled-reports)
|
||||||
15. [Alembic Migration Chain](#15-alembic-migration-chain)
|
15. [Rate Limiting](#15-rate-limiting)
|
||||||
16. [Frontend Conventions](#16-frontend-conventions)
|
16. [Alembic Migration Chain](#16-alembic-migration-chain)
|
||||||
17. [Infrastructure](#17-infrastructure)
|
17. [Frontend Conventions](#17-frontend-conventions)
|
||||||
18. [Known Constraints & Hard Rules](#18-known-constraints--hard-rules)
|
18. [Infrastructure](#18-infrastructure)
|
||||||
19. [Change Philosophy](#19-change-philosophy)
|
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:
|
**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
|
- **Inspection** execution against configurable templates with dynamic form builder
|
||||||
- **Issue** tracking with SLA enforcement, follower subscriptions, and verification workflow
|
- **Issue** tracking with SLA enforcement, follower subscriptions, and verification workflow
|
||||||
- **Customer portal** with scoped facility visibility and invitation-based onboarding
|
- **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) |
|
| Database | MySQL (via PyMySQL driver) |
|
||||||
| Auth (web) | Flask-Login + Flask-WTF CSRF |
|
| Auth (web) | Flask-Login + Flask-WTF CSRF |
|
||||||
| Auth (API) | JWT access tokens + opaque refresh tokens (PyJWT) |
|
| 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 |
|
| Migrations | Flask-Migrate / Alembic |
|
||||||
| Email | Flask-Mail (SMTP, background threading) |
|
| Email | Flask-Mail (SMTP, background threading) |
|
||||||
| PDF generation | ReportLab |
|
| PDF generation | ReportLab |
|
||||||
@@ -75,55 +77,53 @@ The application is actively deployed in production and maintained by a single de
|
|||||||
```
|
```
|
||||||
lt_janitorial_quality_control/
|
lt_janitorial_quality_control/
|
||||||
├── app/
|
├── 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)
|
│ ├── add_form_schema.py # One-off migration helper (safe re-run)
|
||||||
│ ├── api/ # Mobile REST API (Phase 7)
|
│ ├── api/ # Mobile REST API (Phase 7)
|
||||||
│ │ ├── __init__.py # api_bp parent blueprint + register_api()
|
│ │ ├── __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
|
│ │ ├── decorators.py # @jwt_required
|
||||||
│ │ ├── errors.py # JSON error helpers + error handler registration
|
│ │ ├── errors.py # JSON error helpers + error handler registration
|
||||||
│ │ └── jwt_utils.py # generate_access_token()
|
│ │ └── jwt_utils.py # generate_access_token()
|
||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── __init__.py # Re-exports all models
|
│ │ ├── __init__.py
|
||||||
│ │ ├── api_token.py # RefreshToken, DeviceToken
|
│ │ ├── api_token.py # RefreshToken, DeviceToken
|
||||||
│ │ ├── audit.py # AuditLog
|
│ │ ├── audit.py # AuditLog
|
||||||
│ │ ├── facility.py # Facility, Area
|
│ │ ├── facility.py # Facility, Area
|
||||||
│ │ ├── inspection.py # InspectionTemplate, ChecklistItem, Inspection, InspectionResult
|
│ │ ├── inspection.py # InspectionTemplate, ChecklistItem, Inspection, InspectionResult
|
||||||
│ │ ├── issue.py # Issue, IssueComment, IssueFollower
|
│ │ ├── issue.py # Issue, IssueComment, IssueFollower
|
||||||
│ │ ├── notification.py # Notification, NotificationPreference + event constants
|
│ │ ├── notification.py # Notification, NotificationPreference + event constants
|
||||||
│ │ ├── notification_matrix.py # NotificationMatrix + MATRIX_DEFAULTS, MATRIX_EVENTS
|
│ │ ├── notification_matrix.py
|
||||||
│ │ ├── project.py # Project, CustomerAssignment
|
│ │ ├── project.py # Project, CustomerAssignment
|
||||||
│ │ ├── scheduled_report.py # ScheduledReport
|
│ │ ├── scheduled_report.py # ScheduledReport
|
||||||
│ │ └── user.py # User (with password-setup workflow)
|
│ │ └── user.py # User (password-setup workflow, display_name)
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── auth.py # /auth/* — login rate-limited; _safe_next() open-redirect guard
|
||||||
│ │ ├── audit.py # /audit/*
|
│ │ ├── customers.py # /customers/* — _safe_referrer() helper; expired invitation banner
|
||||||
│ │ ├── auth.py # /auth/* (users, login, notification matrix)
|
│ │ ├── dashboard.py # / — single open_issues query (len() not .count())
|
||||||
│ │ ├── customers.py # /customers/*
|
|
||||||
│ │ ├── dashboard.py # /
|
|
||||||
│ │ ├── facilities.py # /facilities/*
|
│ │ ├── facilities.py # /facilities/*
|
||||||
│ │ ├── inspections.py # /inspections/*
|
│ │ ├── inspections.py # /inspections/* — passes now=now_eastern() for stale badge
|
||||||
│ │ ├── issues.py # /issues/*
|
│ │ ├── issues.py # /issues/* — quick-assign AJAX; facility filter; SLA column
|
||||||
│ │ ├── notifications.py # /notifications/*
|
│ │ ├── notifications.py # /notifications/* — send-digest, check-sla, cleanup-tokens
|
||||||
│ │ ├── projects.py # /projects/*
|
│ │ ├── projects.py # /projects/*
|
||||||
│ │ ├── reports.py # /reports/*
|
│ │ ├── reports.py # /reports/*
|
||||||
│ │ ├── scheduled_reports.py # /scheduled-reports/*
|
│ │ ├── scheduled_reports.py # /scheduled-reports/*
|
||||||
│ │ └── templates.py # /templates/*
|
│ │ ├── templates.py # /templates/*
|
||||||
|
│ │ └── audit.py # /audit/*
|
||||||
│ ├── static/
|
│ ├── static/
|
||||||
│ │ ├── css/ipad_responsive.css
|
│ │ ├── css/ipad_responsive.css
|
||||||
│ │ └── uploads/ # User-uploaded photos (gitignored)
|
│ │ └── uploads/
|
||||||
│ ├── templates/ # Jinja2 templates (mirrors routes/ structure)
|
│ ├── templates/
|
||||||
│ │ ├── base.html # Master layout (navbar, sidebar, flash messages)
|
│ │ ├── base.html # Active nav tab styling; request.endpoint-based active detection
|
||||||
│ │ ├── _sla_badge.html # Reusable SLA status badge partial
|
│ │ ├── _sla_badge.html
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── audit.py # log_action() + ACTION_* constants
|
│ ├── audit.py # log_action() + ACTION_* constants
|
||||||
│ ├── decorators.py # @admin_required, @supervisor_required, @project_manager_required, @customer_required
|
│ ├── 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()
|
│ ├── notifications.py # notify(), notify_by_matrix(), send_pending_digests()
|
||||||
│ ├── pdf_export.py # generate_inspection_pdf(), scorecard PDF
|
│ ├── pdf_export.py # ReportLab PDF generation
|
||||||
│ ├── scope.py # get_customer_scope() — facility access for customers
|
│ ├── scope.py # get_customer_scope()
|
||||||
│ ├── sla.py # sla_status(), sla_deadline(), sla_hours_remaining(), send_sla_alerts()
|
│ ├── sla.py # sla_status(), sla_deadline(), sla_hours_remaining(), send_sla_alerts()
|
||||||
│ └── time_utils.py # now_eastern()
|
│ └── time_utils.py # now_eastern()
|
||||||
├── migrations/
|
├── migrations/
|
||||||
@@ -134,12 +134,13 @@ lt_janitorial_quality_control/
|
|||||||
│ ├── phase8_notification_matrix.py
|
│ ├── phase8_notification_matrix.py
|
||||||
│ ├── phase9_user_full_name.py
|
│ ├── phase9_user_full_name.py
|
||||||
│ ├── phase10_customer_password_setup.py
|
│ ├── phase10_customer_password_setup.py
|
||||||
│ └── phase11_director_role.py # HEAD
|
│ ├── phase11_director_role.py
|
||||||
├── config.py # Config, DevelopmentConfig, ProductionConfig
|
│ └── phase12_performance_indexes.py ← HEAD
|
||||||
├── gunicorn_config.py # bind, workers, log paths
|
├── config.py
|
||||||
├── requirements.txt
|
├── gunicorn_config.py
|
||||||
├── run.py # Development entry point
|
├── requirements.txt # Includes Flask-Limiter
|
||||||
└── wsgi.py # Gunicorn entry point
|
├── run.py
|
||||||
|
└── wsgi.py
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -151,38 +152,29 @@ lt_janitorial_quality_control/
|
|||||||
| Variable | Notes |
|
| Variable | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `SECRET_KEY` | Flask secret — no fallback; startup fails if absent |
|
| `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_SERVER` | SMTP hostname |
|
||||||
| `MAIL_USERNAME` | SMTP login |
|
| `MAIL_USERNAME` | SMTP login |
|
||||||
| `MAIL_PASSWORD` | SMTP password |
|
| `MAIL_PASSWORD` | SMTP password |
|
||||||
| `MAIL_PORT` | 465 (SSL) or 587 (STARTTLS) — auto-selects `MAIL_USE_SSL`/`MAIL_USE_TLS` |
|
| `MAIL_PORT` | 465 (SSL) or 587 (STARTTLS) — auto-selects flags |
|
||||||
| `APP_BASE_URL` | Full URL prefix for email links, e.g. `https://jqc.example.com` |
|
| `APP_BASE_URL` | Full URL for email links |
|
||||||
| `MAIL_DEFAULT_SENDER` | From address |
|
| `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
|
### Email SSL Auto-Detection
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# config.py — port 465 → implicit SSL; port 587 → STARTTLS
|
|
||||||
MAIL_USE_SSL = _mail_port == 465
|
MAIL_USE_SSL = _mail_port == 465
|
||||||
MAIL_USE_TLS = not MAIL_USE_SSL
|
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
|
### File Uploads
|
||||||
|
|
||||||
- `UPLOAD_FOLDER` = `app/static/uploads/`
|
- `UPLOAD_FOLDER` = `app/static/uploads/`
|
||||||
- `MAX_CONTENT_LENGTH` = 50 MB (Flask-side limit; Nginx must also be configured)
|
- `MAX_CONTENT_LENGTH` = 50 MB
|
||||||
- Allowed extensions: `png`, `jpg`, `jpeg`, `gif`
|
- Allowed: `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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,85 +183,89 @@ MAIL_USE_TLS = not MAIL_USE_SSL
|
|||||||
### User
|
### User
|
||||||
|
|
||||||
```
|
```
|
||||||
users
|
users: id, username (unique, indexed), full_name, email (unique, indexed),
|
||||||
├── id, username (unique), full_name, email (unique)
|
password_hash, role (ENUM), created_at, active,
|
||||||
├── password_hash, role (ENUM), created_at, active
|
password_set, set_password_token (indexed), set_password_token_expires
|
||||||
├── password_set (Bool) — False until customer completes setup flow
|
|
||||||
├── set_password_token (str/64) — time-limited invitation token
|
|
||||||
└── set_password_token_expires — Eastern datetime
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Role ENUM (current):** `admin`, `director`, `inspector`, `project_manager`, `customer`
|
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`
|
||||||
> `supervisor` was renamed to `director` in Phase 11. The ENUM no longer contains `supervisor`.
|
|
||||||
|
|
||||||
**Key methods:**
|
**Key property:** `display_name` → `full_name.strip()` or falls back to `username`. Use this everywhere in templates — never use `.username` for display.
|
||||||
- `display_name` → `full_name.strip()` or falls back to `username`
|
|
||||||
- `generate_set_password_token(expires_hours=72)` → creates 64-hex token + expiry
|
**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.
|
||||||
- `verify_set_password_token(token)` → returns User or None
|
|
||||||
|
|
||||||
### Facility / Area
|
### 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
|
areas: id, facility_id (FK), name, area_type
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other`
|
||||||
|
|
||||||
### Project / CustomerAssignment
|
### 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)
|
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)
|
inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date,
|
||||||
checklist_items: id, template_id, category, item_description, scoring_type, weight, requires_photo, display_order
|
overall_score, status (in_progress/completed/flagged), notes, form_data (JSON),
|
||||||
inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date, overall_score,
|
completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note
|
||||||
status (in_progress/completed/flagged), notes, form_data (JSON), completed_at,
|
|
||||||
parent_inspection_id (self-FK), follow_up_required, follow_up_note
|
|
||||||
inspection_results: id, inspection_id, checklist_item_id, score, passed, comments, photo_path
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Scoring rule:** Items with `score = 0` mean "unanswered" and are excluded from score calculation entirely.
|
**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),
|
issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critical),
|
||||||
description, photo_path, status (open/in_progress/resolved/pending_verification),
|
description, photo_path, status (open/in_progress/resolved/pending_verification),
|
||||||
assigned_to (FK→users), reported_at, resolved_at, result_notes, result_photos (JSON),
|
assigned_to, reported_at, resolved_at, result_notes, result_photos (JSON),
|
||||||
verified_by, verified_at, verification_note, sla_notified (None/'at_risk'/'breached')
|
verified_by, verified_at, verification_note, sla_notified
|
||||||
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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Indexed columns (Phase 12):** `status`, `severity`, `assigned_to`, `reported_at`
|
||||||
|
|
||||||
**Issue status flow:** `open` → `in_progress` → `pending_verification` → `resolved`
|
**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
|
### 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
|
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
|
### NotificationMatrix
|
||||||
|
|
||||||
```
|
```
|
||||||
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON text)
|
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||||||
└── UniqueConstraint(event_type, role_key)
|
UniqueConstraint(event_type, role_key)
|
||||||
```
|
```
|
||||||
|
|
||||||
### AuditLog
|
### 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
|
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,
|
api_refresh_tokens: id, user_id, token_hash (SHA-256, unique), device_id, device_name,
|
||||||
created_at, expires_at, revoked
|
created_at, expires_at, revoked
|
||||||
api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, registered_at
|
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
|
**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
|
||||||
scheduled_reports: id, name, report_type (summary/facility/issues), frequency (daily/weekly/monthly),
|
|
||||||
facility_id (nullable), recipients (JSON), include_pdf, include_csv,
|
|
||||||
active, created_by, created_at, last_sent_at, next_send_at
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Role & Permission Matrix
|
## 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 |
|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
|
||||||
| Users (`/auth/users`) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| Users | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
|
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
|
||||||
| Customers (`/customers`) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| Customers | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Facilities | ✅ | ✅ | ✅ | ✅ read | ✅ scoped |
|
| Facilities | ✅ | ✅ | ✅ | read | scoped |
|
||||||
| Projects | ✅ | ✅ | ✅ | ✅ read | ✅ scoped |
|
| Contracts | ✅ | ✅ | ✅ | read | scoped |
|
||||||
| Templates (create/edit) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | ✅ read |
|
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
|
||||||
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ read |
|
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | read |
|
||||||
|
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Reports (export) | ✅ | ✅ | ✅ | ✅ | ✅ scoped |
|
| Reports | ✅ | ✅ | ✅ | ✅ | scoped |
|
||||||
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ |
|
| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ |
|
||||||
| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ (TBD) |
|
| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
### Decorator Map
|
### Decorator Map
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@admin_required # role == 'admin' only
|
@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')
|
@project_manager_required # role in ('admin', 'director', 'project_manager')
|
||||||
@customer_required # role == 'customer' only
|
@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
|
## 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` |
|
| `auth` | `/auth` | `/login` (rate-limited 20/min), `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||||
| `dashboard` | `/` | `GET /` |
|
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||||
| `facilities` | `/facilities` | CRUD + `/facilities/<id>/areas` |
|
| `facilities` | `/facilities` | CRUD + area management |
|
||||||
| `projects` | `/projects` | CRUD + assignment management |
|
| `projects` | `/projects` | CRUD + customer assignment management |
|
||||||
| `customers` | `/customers` | list, create, edit, delete, manage assignments, invite, set-password |
|
| `customers` | `/customers` | list (expired invitation banner), invite, set-password, manage, import CSV |
|
||||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag issue |
|
| `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 |
|
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||||||
| `issues` | `/issues` | list, view, create, update, assign, verify, comment, follow/unfollow, verification queue |
|
| `issues` | `/issues` | list (SLA column, facility filter, quick-assign dropdown), view, create, update, verify, comment, follow/unfollow, verification queue, delete |
|
||||||
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron) |
|
| `issues` | `/issues` | `POST /<id>/quick-assign` — JSON AJAX, admin/director only |
|
||||||
| `audit` | `/audit` | list (admin-only), view |
|
| `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 |
|
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export |
|
||||||
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger |
|
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger |
|
||||||
| `api` (parent) | `/api/v1` | — |
|
| `api` | `/api/v1` | parent blueprint |
|
||||||
| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` |
|
| `api_auth` | `/api/v1` | `/auth/login` (10/min), `/auth/refresh` (30/min), `/auth/logout`, `/auth/me`, `/devices/register` |
|
||||||
|
|
||||||
### API Blueprint Registration
|
|
||||||
|
|
||||||
The mobile API uses a **parent + child** pattern:
|
|
||||||
```python
|
|
||||||
# app/api/__init__.py
|
|
||||||
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
|
||||||
|
|
||||||
def register_api(app):
|
|
||||||
from app.api.auth import bp as auth_bp
|
|
||||||
api_bp.register_blueprint(auth_bp)
|
|
||||||
app.register_blueprint(api_bp)
|
|
||||||
```
|
|
||||||
|
|
||||||
CSRF is exempted **before** registration:
|
|
||||||
```python
|
|
||||||
csrf.exempt(api_bp)
|
|
||||||
register_api(app)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Utility Modules
|
## 8. Utility Modules
|
||||||
|
|
||||||
### `app/utils/time_utils.py`
|
### `time_utils.py`
|
||||||
|
`now_eastern()` — always use this, never `datetime.utcnow()`.
|
||||||
|
|
||||||
```python
|
### `audit.py`
|
||||||
from app.utils.time_utils import now_eastern
|
`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
|
### `notifications.py`
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT, ACTION_EXPORT
|
`notify()`, `notify_by_matrix()`, `notify_customers_for_facility()` — all email sent in background thread.
|
||||||
|
|
||||||
log_action(
|
### `sla.py`
|
||||||
action = ACTION_CREATE,
|
`sla_status(issue)` → `'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
|
||||||
entity_type = 'Facility',
|
`send_sla_alerts()` — called by cron, deduplicates via `issue.sla_notified`.
|
||||||
entity_id = facility.id,
|
|
||||||
entity_label = facility.name,
|
|
||||||
details = f'address={facility.address}',
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Must be called **after** `db.session.commit()` to capture the entity's ID
|
### `pdf_export.py`
|
||||||
- Never raises — failures are logged but never bubble up to break the primary request
|
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.
|
||||||
- Snapshots `username` and `user_role` so records survive user deletion
|
|
||||||
|
|
||||||
### `app/utils/decorators.py`
|
|
||||||
|
|
||||||
Four decorators: `admin_required`, `supervisor_required`, `project_manager_required`, `customer_required`.
|
|
||||||
All redirect to `dashboard.index` with a flash message on unauthorized access.
|
|
||||||
|
|
||||||
### `app/utils/scope.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from app.utils.scope import get_customer_scope
|
|
||||||
|
|
||||||
facility_ids = get_customer_scope(current_user) # None for non-customers
|
|
||||||
if facility_ids is not None:
|
|
||||||
q = q.filter(Inspection.facility_id.in_(facility_ids))
|
|
||||||
```
|
|
||||||
|
|
||||||
### `app/utils/forms.py`
|
|
||||||
|
|
||||||
All WTForms classes live here:
|
|
||||||
- `LoginForm`, `ProfileForm`, `UserForm` (excludes `customer` role — use `/customers`)
|
|
||||||
- `FacilityForm`, `AreaForm`
|
|
||||||
- `InspectionTemplateForm`, `ChecklistItemForm`, `StartInspectionForm`
|
|
||||||
- `IssueForm`, `IssueUpdateForm`
|
|
||||||
- `ProjectForm`, `ProjectAssignmentForm`
|
|
||||||
- `CustomerUserForm`, `CustomerAssignmentForm`, `CustomerInviteForm`, `SetPasswordForm`
|
|
||||||
- `ScheduledReportForm`
|
|
||||||
|
|
||||||
### `app/utils/notifications.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from app.utils.notifications import notify, notify_by_matrix, notify_customers_for_facility
|
|
||||||
|
|
||||||
notify(
|
|
||||||
recipient = user_obj,
|
|
||||||
title = 'Issue Updated',
|
|
||||||
body = 'Status changed to resolved.',
|
|
||||||
link = '/issues/42',
|
|
||||||
issue_id = 42,
|
|
||||||
event_type = EVENT_ISSUE_STATUS,
|
|
||||||
send_email = True,
|
|
||||||
)
|
|
||||||
|
|
||||||
notify_by_matrix(
|
|
||||||
event_type = 'inspection_completed',
|
|
||||||
title = '...',
|
|
||||||
body = '...',
|
|
||||||
link = '...',
|
|
||||||
issue_id = None,
|
|
||||||
exclude_user_ids = {assignee.id}, # deduplicate
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Email is sent in a background thread so it never blocks the HTTP response.
|
|
||||||
|
|
||||||
### `app/utils/pdf_export.py`
|
|
||||||
|
|
||||||
- `generate_inspection_pdf(inspection)` — full ReportLab inspection report with 12-column CSS grid preserved in print
|
|
||||||
- Scorecard generation for facility/project reports
|
|
||||||
- **Critical:** The 12-column grid layout must not be collapsed to single-column in print/PDF views
|
|
||||||
|
|
||||||
### `app/utils/sla.py`
|
|
||||||
|
|
||||||
SLA thresholds: `critical=4h`, `high=24h`, `medium=72h`, `low=168h`
|
|
||||||
At-risk threshold: 75% of window elapsed.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from app.utils.sla import sla_status, sla_deadline, sla_hours_remaining, send_sla_alerts
|
|
||||||
|
|
||||||
status = sla_status(issue) # 'ok' | 'at_risk' | 'breached' | None (resolved)
|
|
||||||
```
|
|
||||||
|
|
||||||
`send_sla_alerts()` is called by cron. It deduplicates using `issue.sla_notified` — once `breached` is recorded, no further alerts fire for that issue.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Mobile API (Phase 7)
|
## 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)
|
### Rate Limits
|
||||||
2. App stores both in iOS Keychain
|
| Endpoint | Limit |
|
||||||
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)
|
| `POST /api/v1/auth/login` | 10/min, 3/sec |
|
||||||
5. `POST /api/v1/auth/logout` → revokes refresh token
|
| `POST /api/v1/auth/refresh` | 30/min, 5/sec |
|
||||||
|
| `POST /auth/login` (web) | 20/min, 5/sec |
|
||||||
|
|
||||||
### JWT Details
|
### Refresh Token Cleanup
|
||||||
|
- Raw token never stored — SHA-256 hash only
|
||||||
- Library: PyJWT
|
- Passive: expired/revoked tokens purged per-user on each login
|
||||||
- Access token lifetime: 60 minutes (configurable in `jwt_utils.py`)
|
- Cron: `POST /notifications/cleanup-tokens`
|
||||||
- Payload: `sub` (user ID), `exp`, `iat`, `role`
|
|
||||||
- `@jwt_required` decorator extracts and validates the token; sets `g.api_user`
|
|
||||||
|
|
||||||
### Refresh Token Storage
|
|
||||||
|
|
||||||
- Raw token is **never stored** — only its SHA-256 hex digest (`token_hash`)
|
|
||||||
- `RefreshToken.verify(raw_token)` hashes and looks up; returns None if expired/revoked
|
|
||||||
- Token rotation: old row's `revoked = True`, new row inserted
|
|
||||||
|
|
||||||
### CSRF Exemption Pattern
|
### CSRF Exemption Pattern
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# app/__init__.py — order matters
|
csrf.exempt(api_bp) # BEFORE register_api(app) — order matters
|
||||||
csrf = CSRFProtect() # module-level instance
|
|
||||||
csrf.init_app(app) # in create_app()
|
|
||||||
csrf.exempt(api_bp) # BEFORE register_api(app)
|
|
||||||
register_api(app)
|
register_api(app)
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Never** use per-route decorators (`@csrf.exempt`) — Flask-WTF 1.0+ removed that API.
|
### Phase 2 (planned)
|
||||||
|
`api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos` — all stubbed in `app/api/__init__.py`.
|
||||||
### Phase 2 Extensions (planned)
|
|
||||||
|
|
||||||
Commented stubs in `app/api/__init__.py`:
|
|
||||||
- `api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Notification System
|
## 10. Notification System
|
||||||
|
|
||||||
### Event Types (constants in `app/models/notification.py`)
|
### Event Constants (`app/models/notification.py`)
|
||||||
|
|
||||||
```
|
```
|
||||||
EVENT_INSPECTION_DONE = 'inspection_completed'
|
inspection_completed, issue_assigned, issue_reassigned, issue_unassigned,
|
||||||
EVENT_ISSUE_ASSIGNED = 'issue_assigned'
|
issue_status, issue_comment, issue_follow_update, issue_flagged, issue_created,
|
||||||
EVENT_ISSUE_REASSIGNED = 'issue_reassigned'
|
issue_updated_customer, verification_requested, sla_alert,
|
||||||
EVENT_ISSUE_UNASSIGNED = 'issue_unassigned'
|
customer_inspection_completed
|
||||||
EVENT_ISSUE_STATUS = 'issue_status'
|
|
||||||
EVENT_ISSUE_COMMENT = 'issue_comment'
|
|
||||||
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
|
|
||||||
EVENT_ISSUE_FLAGGED = 'issue_flagged'
|
|
||||||
EVENT_ISSUE_CREATED = 'issue_created'
|
|
||||||
EVENT_CUSTOMER_ISSUE_UPDATED = 'issue_updated_customer'
|
|
||||||
EVENT_VERIFICATION_REQUESTED = 'verification_requested'
|
|
||||||
EVENT_SLA_ALERT = 'sla_alert'
|
|
||||||
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Notification Matrix
|
### 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).
|
| Endpoint | Purpose | Schedule |
|
||||||
|
|---|---|---|
|
||||||
- `role_key` values: `admin`, `director`, `inspector`, `project_manager`, `customer`, `custom`
|
| `POST /notifications/send-digest` | Digest email delivery | `0 7 * * *` |
|
||||||
- `custom` role_key stores a JSON list of arbitrary email addresses
|
| `POST /notifications/check-sla` | SLA breach/at-risk alerts | `*/30 * * * *` |
|
||||||
- Matrix rows are auto-created from `MATRIX_DEFAULTS` on first access (`get_matrix_row()`)
|
| `POST /notifications/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` |
|
||||||
|
|
||||||
### Implicit Notifications (always sent, not matrix-controlled)
|
### Implicit Notifications (always sent, not matrix-controlled)
|
||||||
|
- Issue **assignee** on assignment/status/comment
|
||||||
- Issue **assignee** — always notified on assignment/status changes
|
- Issue **followers** on any update
|
||||||
- Issue **followers** — always notified on any update
|
- SLA **assignee + followers** on SLA events
|
||||||
- SLA **assignee + followers** — always notified on SLA events
|
|
||||||
|
|
||||||
### Notification Preferences
|
|
||||||
|
|
||||||
Users can configure per-event preferences at `/notifications/preferences`:
|
|
||||||
- `email_enabled` — receive email at all
|
|
||||||
- `digest_mode` — batch into digest rather than immediate send
|
|
||||||
- `digest_frequency` — daily/weekly/monthly
|
|
||||||
|
|
||||||
### Digest Cron
|
|
||||||
|
|
||||||
`POST /notifications/send-digest?frequency=daily&secret=<DIGEST_SECRET>` — triggered by server cron, authenticates via `DIGEST_SECRET`.
|
|
||||||
|
|
||||||
### Email Delivery
|
|
||||||
|
|
||||||
Emails are dispatched in a background thread:
|
|
||||||
```python
|
|
||||||
t = threading.Thread(target=_send_email_thread, args=(...))
|
|
||||||
t.daemon = True
|
|
||||||
t.start()
|
|
||||||
```
|
|
||||||
|
|
||||||
Never blocks the HTTP response. Failures are logged but not raised.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. SLA Engine
|
## 11. SLA Engine
|
||||||
|
|
||||||
| Severity | Window | At-Risk Trigger |
|
| Severity | Window | At-Risk |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| critical | 4 hours | 3 hours |
|
| critical | 4h | 3h |
|
||||||
| high | 24 hours | 18 hours |
|
| high | 24h | 18h |
|
||||||
| medium | 72 hours | 54 hours |
|
| medium | 72h | 54h |
|
||||||
| low | 168 hours (7 days) | 126 hours |
|
| low | 168h | 126h |
|
||||||
|
|
||||||
- Cron calls `send_sla_alerts()` from within Flask app context
|
SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron notifications.
|
||||||
- `Issue.sla_notified` prevents duplicate notifications — tracked per-issue at level (`at_risk` / `breached`)
|
|
||||||
- `breached` supersedes `at_risk` — user receives two notifications total (once at-risk, once breached)
|
|
||||||
- SLA does not apply once `issue.status == 'resolved'`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Audit Trail
|
## 12. Audit Trail
|
||||||
|
|
||||||
Covered entities: `User`, `Facility`, `Area`, `Template`, `ChecklistItem`, `Inspection`, `Issue`, `IssueComment`, `Project`, `CustomerAssignment`, `ScheduledReport`, `NotificationMatrix`
|
- Admin-only at `/audit/` — director is excluded
|
||||||
|
- Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT`
|
||||||
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`
|
||||||
- Admin-only access: `/audit/` and `/audit/<id>`
|
|
||||||
- Director role: **excluded** from viewing the Audit Trail
|
|
||||||
- IP captured from `X-Forwarded-For` (Nginx) or `request.remote_addr`
|
|
||||||
- `username` and `user_role` are snapshotted at write time — survives user deletion (`ondelete='SET NULL'`)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. PDF Export
|
## 13. PDF Export
|
||||||
|
|
||||||
Uses **ReportLab** directly (not weasyprint or wkhtmltopdf). Located in `app/utils/pdf_export.py`.
|
ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF.
|
||||||
|
|
||||||
- Inspection PDF: full checklist results, scores, photos, notes, follow-up status
|
|
||||||
- Scorecard PDF: facility/project performance summary with charts
|
|
||||||
- **12-column grid must be preserved** — do not collapse to single-column in PDF views
|
|
||||||
|
|
||||||
Reports also support CSV export (Python `csv` module, streamed as `Response`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Scheduled Reports
|
## 14. Scheduled Reports
|
||||||
|
|
||||||
`ScheduledReport` records drive recurring email reports:
|
Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`.
|
||||||
- Types: `summary` (KPI digest), `facility` (single facility scorecard), `issues` (open issues list)
|
Cron: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
|
||||||
- Frequencies: `daily`, `weekly`, `monthly`
|
|
||||||
- Attachments: optional PDF and/or CSV via `include_pdf` / `include_csv`
|
|
||||||
- `next_send_at` and `last_sent_at` track execution state
|
|
||||||
- Cron trigger: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Alembic Migration Chain
|
## 15. Rate Limiting
|
||||||
|
|
||||||
```
|
Initialised in `app/__init__.py` as a module-level extension:
|
||||||
phase1_projects_roles
|
|
||||||
└── phase6_features
|
```python
|
||||||
└── phase7_mobile_api
|
limiter = Limiter(
|
||||||
└── phase8_notification_matrix
|
key_func = get_remote_address,
|
||||||
└── phase9_user_full_name
|
default_limits = [],
|
||||||
└── phase10_customer_password_setup
|
storage_uri = 'memory://', # ← swap for 'redis://...' in multi-worker
|
||||||
└── phase11_director_role ← HEAD
|
)
|
||||||
|
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
|
```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;
|
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL;
|
||||||
|
-- 2. Migrate data
|
||||||
-- Step 2: Migrate existing data
|
|
||||||
UPDATE users SET role = 'director' WHERE role = 'supervisor';
|
UPDATE users SET role = 'director' WHERE role = 'supervisor';
|
||||||
|
-- 3. Contract to remove old value
|
||||||
-- Step 3: Contract ENUM to remove old value
|
|
||||||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
|
ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding New Migrations
|
### 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.
|
||||||
```bash
|
|
||||||
flask db migrate -m "description"
|
|
||||||
flask db upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
All migration scripts include existence checks for safe re-runs.
|
|
||||||
|
|
||||||
### Deprecated SQLAlchemy Patterns
|
### Deprecated SQLAlchemy Patterns
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# WRONG — deprecated in SQLAlchemy 2.x
|
# WRONG
|
||||||
User.query.get(user_id)
|
Model.query.get(id)
|
||||||
|
Model.query.get_or_404(id)
|
||||||
|
|
||||||
# CORRECT
|
# 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)
|
### Display Names
|
||||||
- Chart.js (CDN, for dashboard charts)
|
Always use `user.display_name` in templates — never `.username` for display purposes.
|
||||||
- Navbar includes unread notification badge (injected via `@app.context_processor`)
|
|
||||||
- `pending_verification_count` badge for admin/director roles
|
### Status Label Map
|
||||||
- Jinja2 globals available in all templates: `csrf_token`, `enumerate`, `sla_status`, `sla_deadline`, `sla_hours_remaining`, `SLA_HOURS`
|
| DB value | Displayed as |
|
||||||
|
|---|---|
|
||||||
|
| `completed` | **Submitted** |
|
||||||
|
| `in_progress` | In Progress |
|
||||||
|
| `flagged` | Flagged |
|
||||||
|
| `open` | Open |
|
||||||
|
| `resolved` | Resolved |
|
||||||
|
| `pending_verification` | Pending Verification |
|
||||||
|
|
||||||
### Forms
|
### 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
|
### Real-Time
|
||||||
- Manual forms (without a WTForms object) must include `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">`
|
**SSE banned.** All "live" updates use polling.
|
||||||
- **Never nest `<form>` tags** — browsers silently discard inner forms. Toggle/action forms must be siblings, not children, of edit forms.
|
|
||||||
|
|
||||||
### File Uploads in Templates
|
|
||||||
|
|
||||||
Photos are served from `app/static/uploads/` via `url_for('static', filename=path)`.
|
|
||||||
|
|
||||||
### Real-Time Updates
|
|
||||||
|
|
||||||
**SSE is banned.** Server-Sent Events exhausted the Gunicorn sync worker pool in production. All "live" updates use polling (JS `setInterval` + `fetch`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Infrastructure
|
## 18. Infrastructure
|
||||||
|
|
||||||
### Gunicorn (`gunicorn_config.py`)
|
|
||||||
|
|
||||||
|
### Gunicorn
|
||||||
```python
|
```python
|
||||||
bind = "127.0.0.1:8000"
|
bind = "127.0.0.1:8000"
|
||||||
workers = multiprocessing.cpu_count() * 2 + 1
|
workers = multiprocessing.cpu_count() * 2 + 1
|
||||||
worker_class = "sync" # SSE would require 'gevent' or 'eventlet' — banned
|
worker_class = "sync"
|
||||||
timeout = 30
|
timeout = 30
|
||||||
```
|
```
|
||||||
|
Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
|
||||||
Log paths: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
|
|
||||||
|
|
||||||
### Application Logging
|
### Application Logging
|
||||||
|
|
||||||
Configured in `create_app()`:
|
|
||||||
- `RotatingFileHandler` → `logs/jqc.log` (5 × 5 MB)
|
- `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`
|
- Format: `[YYYY-MM-DD HH:MM:SS] LEVEL in module: message`
|
||||||
- Applied to both `app.logger` and the root logger
|
|
||||||
|
|
||||||
### Nginx
|
### Nginx
|
||||||
|
- `client_max_body_size 50M`
|
||||||
|
- Passes `X-Forwarded-For`
|
||||||
|
|
||||||
- Reverse proxies to `127.0.0.1:8000`
|
### Recommended Cron Schedule
|
||||||
- Must set `client_max_body_size 50M` to match `MAX_CONTENT_LENGTH`
|
|
||||||
- Passes `X-Forwarded-For` for IP capture in audit logs
|
|
||||||
|
|
||||||
### Deployment Sequence (typical change)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
0 7 * * * curl -s -X POST https://your-domain.com/notifications/send-digest \
|
||||||
pip install -r requirements.txt # if dependencies changed
|
-d "token=SECRET&frequency=daily"
|
||||||
flask db upgrade # if migrations added
|
*/30 * * * * curl -s -X POST https://your-domain.com/notifications/check-sla \
|
||||||
sudo systemctl restart jqc # or equivalent gunicorn restart
|
-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 |
|
| # | Rule | Rationale |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | **No SSE** | Exhausted Gunicorn sync worker pool in production |
|
| 1 | **No SSE** | Exhausted Gunicorn sync worker pool |
|
||||||
| 2 | **`now_eastern()` always** | `datetime.utcnow()` caused incorrect SLA purge cutoffs |
|
| 2 | **`now_eastern()` always** | `utcnow()` caused incorrect SLA cutoffs |
|
||||||
| 3 | **3-step MySQL ENUM changes** | Skipping steps causes data loss |
|
| 3 | **3-step MySQL ENUM changes** | Skipping causes data loss |
|
||||||
| 4 | **Port 465 → SSL, port 587 → STARTTLS** | Both flags True breaks Flask-Mail |
|
| 4 | **Port 465 → SSL; 587 → STARTTLS** | Both True breaks Flask-Mail |
|
||||||
| 5 | **`CSRFProtect` as extension instance, not per-route** | Flask-WTF 1.0+ removed `@csrf_exempt` |
|
| 5 | **`CSRFProtect` as extension instance** | Flask-WTF 1.0+ removed per-route `@csrf_exempt` |
|
||||||
| 6 | **`supervisor_required` decorator name preserved** | Changing it would touch 30+ route decorators |
|
| 6 | **`supervisor_required` name preserved** | Renaming would touch 30+ route decorators |
|
||||||
| 7 | **Score 0 = unanswered, exclude from calculation** | Not the same as a score of zero |
|
| 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 views |
|
| 8 | **12-column grid in PDF** | Must not collapse in print/PDF |
|
||||||
| 9 | **No nested `<form>` tags** | Browsers silently discard inner forms |
|
| 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 |
|
| 11 | **`db.session.get(Model, id)` not `Model.query.get(id)`** | SQLAlchemy 2.x deprecation |
|
||||||
| 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement |
|
| 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement |
|
||||||
| 13 | **Bulk queries in customer list** | Per-customer query loop caused N+1 in `/customers` |
|
| 13 | **Bulk queries in customer list** | Per-customer loops cause N+1 |
|
||||||
| 14 | **Email sent in background thread** | Never block HTTP response |
|
| 14 | **Email in background thread** | Never block HTTP response |
|
||||||
| 15 | **Open-redirect guard on login** | `_safe_next()` validates relative URLs only |
|
| 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
|
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
|
3. **Never remove existing functionality** unless explicitly directed
|
||||||
4. **Log all create/update/delete actions** via `log_action()`
|
4. **Log all create/update/delete actions** via `log_action()`
|
||||||
5. **Test migrations with existence checks** so they are safe to re-run
|
5. **Migration existence checks** — all migrations safe to re-run
|
||||||
6. **Full file contents for 1–3 file changes**; zip package with placement map for larger changesets
|
6. **Full file contents for 1–3 file changes**; deployment map for larger changesets
|
||||||
7. **Explicit deploy instructions** always included (migration steps separated from code changes)
|
7. **Explicit deploy instructions** — migration steps separated from code steps
|
||||||
8. **Root cause analysis** on errors — never apply temporary workarounds
|
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
|
## 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
|
- **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
|
- **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)
|
- **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 and digest mode
|
- **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)
|
- **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
|
- **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
|
- **Mobile API** — JWT-authenticated REST API (Phase 7) for the companion React Native / Expo mobile application; rate-limited login and refresh endpoints
|
||||||
- **Project Hierarchy** — Facilities grouped into Projects with optional Project Manager assignment and per-project customer access control
|
- **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 |
|
| ORM / Migrations | SQLAlchemy + Alembic |
|
||||||
| Auth (web) | Flask-Login + Flask-WTF (CSRF) |
|
| Auth (web) | Flask-Login + Flask-WTF (CSRF) |
|
||||||
| Auth (API) | JWT + opaque refresh tokens |
|
| Auth (API) | JWT + opaque refresh tokens |
|
||||||
|
| Rate limiting | Flask-Limiter |
|
||||||
| Email | Flask-Mail (SMTP) |
|
| Email | Flask-Mail (SMTP) |
|
||||||
| PDF | ReportLab |
|
| PDF | ReportLab |
|
||||||
| Frontend | Bootstrap 5, Chart.js, Jinja2 |
|
| Frontend | Bootstrap 5, Chart.js, Jinja2 |
|
||||||
@@ -37,7 +38,7 @@ A production-grade web application for managing janitorial service contracts, fa
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
- MySQL 8.x
|
- MySQL 5.7+ (or 8.0+)
|
||||||
- A configured SMTP server (port 465 or 587)
|
- 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>
|
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
|
### 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/`.
|
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 (recommended configuration)
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
@@ -163,31 +166,24 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
## Cron Jobs
|
## Cron Jobs
|
||||||
|
|
||||||
Two background tasks require scheduled execution:
|
Four background tasks require scheduled execution. All endpoints that require a token use `DIGEST_SECRET`.
|
||||||
|
|
||||||
### SLA Alerts
|
|
||||||
|
|
||||||
Checks all open issues against their SLA window and dispatches notifications:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run every 30 minutes
|
# SLA alerts — every 30 minutes
|
||||||
*/30 * * * * /home/jqc/venv/bin/python -c "
|
*/30 * * * * curl -s -X POST "https://your-domain.com/notifications/check-sla" \
|
||||||
from app import create_app
|
-d "token=YOUR_DIGEST_SECRET"
|
||||||
from app.utils.sla import send_sla_alerts
|
|
||||||
app = create_app('production')
|
|
||||||
with app.app_context():
|
|
||||||
send_sla_alerts()
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
# Expired/revoked API token cleanup — 3:00 AM
|
||||||
# Daily digest — run at 7:00 AM
|
0 3 * * * curl -s -X POST "https://your-domain.com/notifications/cleanup-tokens" \
|
||||||
0 7 * * * curl -s -X POST "https://your-domain.com/notifications/send-digest?frequency=daily&secret=YOUR_DIGEST_SECRET"
|
-d "token=YOUR_DIGEST_SECRET"
|
||||||
|
|
||||||
# Scheduled reports
|
# Scheduled report delivery — 8:00 AM
|
||||||
0 8 * * * curl -s -X POST "https://your-domain.com/scheduled-reports/run?secret=YOUR_DIGEST_SECRET"
|
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 |
|
| **admin** | Full access to all features including Audit Trail and Notification Matrix |
|
||||||
| **director** | Broad access equivalent to admin, excluding 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 |
|
| **inspector** | Executes inspections and manages assigned issues |
|
||||||
| **customer** | Read-only portal scoped to assigned facilities; receives notifications on their facilities |
|
| **customer** | Read-only portal scoped to assigned facilities; receives notifications on their facilities |
|
||||||
|
|
||||||
@@ -218,11 +214,13 @@ flask shell
|
|||||||
|
|
||||||
## Customer Onboarding
|
## Customer Onboarding
|
||||||
|
|
||||||
1. Admin navigates to **Customers → Invite Customer**
|
1. Admin navigates to **Customers → New Customer**
|
||||||
2. Enter the customer's name and email address
|
2. Enter the customer's name and email — username is auto-generated
|
||||||
3. The system auto-generates a username and sends an invitation email with a 72-hour setup link
|
3. The system 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
|
4. Customer sets their password via the link and gains access to their scoped portal
|
||||||
5. Admin assigns the customer to one or more Projects/Facilities via **Customers → Manage Assignments**
|
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
|
### Authentication Endpoints
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Rate Limit | Description |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | `/api/v1/auth/login` | Login; returns access + refresh tokens |
|
| POST | `/api/v1/auth/login` | 10/min | Login; returns access + refresh tokens |
|
||||||
| POST | `/api/v1/auth/refresh` | Rotate refresh token; returns new access token |
|
| POST | `/api/v1/auth/refresh` | 30/min | Rotate refresh token; returns new access token |
|
||||||
| POST | `/api/v1/auth/logout` | Revoke refresh token |
|
| POST | `/api/v1/auth/logout` | — | Revoke refresh token |
|
||||||
| GET | `/api/v1/auth/me` | Return current user profile |
|
| GET | `/api/v1/auth/me` | — | Return current user profile |
|
||||||
| POST | `/api/v1/devices/register` | Register APNs device token for push notifications |
|
| 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
|
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_PASSWORD` | — | SMTP password |
|
||||||
| `MAIL_DEFAULT_SENDER` | `noreply@janitorialqc.local` | From address |
|
| `MAIL_DEFAULT_SENDER` | `noreply@janitorialqc.local` | From address |
|
||||||
| `APP_BASE_URL` | `""` | Base URL for links in emails |
|
| `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 |
|
| `MAX_CONTENT_LENGTH` | `50MB` | Maximum upload size per request |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user