From ff0650e1d90419093d7b8342b0203b636deea0b2 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 25 Apr 2026 13:14:00 -0400 Subject: [PATCH] Updated documents --- CLAUDE.md | 707 +++++++++++++++++++++--------------------------------- README.md | 87 +++---- 2 files changed, 320 insertions(+), 474 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93ef357..b1f6f5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//quick-assign` — AJAX endpoint for admin/director. Returns JSON. Sends assignment notification. Logs to audit trail. + ### Notification / NotificationPreference ``` -notifications: id, user_id, title, body, link, is_read, created_at, issue_id (nullable), event_type +notifications: id, user_id, title, body, link, is_read, created_at, issue_id, event_type notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency ``` +**Pause all emails:** Preferences page has a "Pause All Emails" toggle that bulk-disables all email toggles client-side. + ### NotificationMatrix ``` -notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON text) - └── UniqueConstraint(event_type, role_key) +notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON) + UniqueConstraint(event_type, role_key) ``` ### AuditLog ``` -audit_logs: id, user_id (FK nullable on delete SET NULL), username (snapshot), user_role (snapshot), +audit_logs: id, user_id (nullable, SET NULL on delete), username (snapshot), user_role (snapshot), action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address ``` @@ -278,496 +274,345 @@ audit_logs: id, user_id (FK nullable on delete SET NULL), username (snapshot), u ``` api_refresh_tokens: id, user_id, token_hash (SHA-256, unique), device_id, device_name, created_at, expires_at, revoked -api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, registered_at - └── UniqueConstraint(user_id, device_id) +api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, registered_at + UniqueConstraint(user_id, device_id) ``` -### ScheduledReport - -``` -scheduled_reports: id, name, report_type (summary/facility/issues), frequency (daily/weekly/monthly), - facility_id (nullable), recipients (JSON), include_pdf, include_csv, - active, created_by, created_at, last_sent_at, next_send_at -``` +**Token cleanup:** +1. **Passive** — on every successful API login, expired/revoked tokens for that user are deleted +2. **Cron** — `POST /notifications/cleanup-tokens?token=` deletes all globally --- ## 6. Role & Permission Matrix -| Route Area | admin | director | project_manager | inspector | customer | +| Area | admin | director | project_manager | inspector | customer | |---|---|---|---|---|---| | Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped | -| Users (`/auth/users`) | ✅ | ✅ | ❌ | ❌ | ❌ | +| Users | ✅ | ✅ | ❌ | ❌ | ❌ | | Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ | -| Customers (`/customers`) | ✅ | ✅ | ❌ | ❌ | ❌ | -| Facilities | ✅ | ✅ | ✅ | ✅ read | ✅ scoped | -| Projects | ✅ | ✅ | ✅ | ✅ read | ✅ scoped | -| Templates (create/edit) | ✅ | ✅ | ❌ | ❌ | ❌ | -| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | ✅ read | -| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ read | +| Customers | ✅ | ✅ | ❌ | ❌ | ❌ | +| Facilities | ✅ | ✅ | ✅ | read | scoped | +| Contracts | ✅ | ✅ | ✅ | read | scoped | +| Templates | ✅ | ✅ | ❌ | ❌ | ❌ | +| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read | +| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | read | +| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ | | Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ | -| Reports (export) | ✅ | ✅ | ✅ | ✅ | ✅ scoped | +| Reports | ✅ | ✅ | ✅ | ✅ | scoped | | Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ | | Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ | -| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ (TBD) | +| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ | ### Decorator Map ```python -@admin_required # role == 'admin' only -@supervisor_required # role in ('admin', 'director') — intentionally kept as-is (Phase 11) -@project_manager_required # role in ('admin', 'director', 'project_manager') -@customer_required # role == 'customer' only +@admin_required # role == 'admin' only +@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes +@project_manager_required # role in ('admin', 'director', 'project_manager') +@customer_required # role == 'customer' only ``` -> **Important:** `@supervisor_required` is **not** renamed to `director_required` — that would require touching 30+ route decorators. The function name is a legacy artefact; the access list is correct. - -### Customer Scoping - -Customer visibility is derived from `CustomerAssignment` rows via `get_customer_scope(user)`: -- Returns `list[int]` of facility IDs, or `None` for non-customer roles -- `facility_id = NULL` assignment → access to all active facilities in that project -- All customer-facing queries must filter by `facility_id.in_(scope)` or `area.facility_id.in_(scope)` - --- ## 7. Blueprint Prefixes & Route Inventory -| Blueprint | Prefix | Key Routes | +| Blueprint | Prefix | Notable routes | |---|---|---| -| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users`, `/users/new`, `/users//edit`, `/users//delete`, `/users//toggle-active`, `/notification-matrix` | -| `dashboard` | `/` | `GET /` | -| `facilities` | `/facilities` | CRUD + `/facilities//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 //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 ` -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=` — 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/` -- 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=` +Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`. +Cron: `POST /scheduled-reports/run?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