From b9df76fa46cef6e5a4910d3b4130793747324faa Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 27 Apr 2026 13:56:25 -0400 Subject: [PATCH] 04/27 Updated documents --- CLAUDE.md | 36 +++++++++++++++++++++++++++++------- README.md | 8 ++++++-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b1f6f5e..153b1c3 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 12 complete) +> **Last reviewed:** April 2026 (Phase 12 complete + post-review hardening patch) --- @@ -58,7 +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) | +| Rate limiting | Flask-Limiter (Redis-backed in production via `REDIS_URL`; falls back to in-process memory for dev) | | Migrations | Flask-Migrate / Alembic | | Email | Flask-Mail (SMTP, background threading) | | PDF generation | ReportLab | @@ -160,6 +160,7 @@ lt_janitorial_quality_control/ | `APP_BASE_URL` | Full URL for email links | | `MAIL_DEFAULT_SENDER` | From address | | `DIGEST_SECRET` | Authenticates all cron endpoints: `send-digest`, `check-sla`, `cleanup-tokens`, `scheduled-reports/run` | +| `REDIS_URL` | Optional. e.g. `redis://127.0.0.1:6379/0`. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. Falls back to in-process memory if absent. | ### Email SSL Auto-Detection @@ -194,6 +195,8 @@ users: id, username (unique, indexed), full_name, email (unique, indexed), **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. +**Token verification:** `User.verify_set_password_token()` uses `hmac.compare_digest()` as a constant-time comparison guard after the DB lookup and expiry check, preventing timing oracle attacks on the stored token value. + ### Facility / Area ``` @@ -246,6 +249,8 @@ issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critica **Quick-assign:** `POST /issues//quick-assign` — AJAX endpoint for admin/director. Returns JSON. Sends assignment notification. Logs to audit trail. +**Assignable staff roles:** `admin`, `director`, `inspector` — all three appear in the assignment dropdown on the issue view, create, and list quick-assign forms. `admin` was previously absent from the view/create dropdowns; corrected. + ### Notification / NotificationPreference ``` @@ -346,7 +351,7 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version `log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. ### `scope.py` -`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff. +`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff. Uses a single bulk `Facility.project_id.in_(...)` query for project-scoped assignments — never N+1. ### `forms.py` All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role. @@ -430,6 +435,8 @@ customer_inspection_completed SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron notifications. +**SLA filter on issues list:** When `?sla=` is active, the full matching result set is loaded and filtered in Python, then wrapped in `_SLAFilteredPage` (defined at the top of `routes/issues.py`). This satisfies the template's pagination interface (`.items`, `.page`, `.pages`, `.iter_pages()`) without any template changes. Pagination nav is automatically suppressed via the existing `{% if issues.pages > 1 %}` guard. + --- ## 12. Audit Trail @@ -439,6 +446,10 @@ SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron no - Immutable — never updated or deleted through the application (purge UI exists for old records) - IP from `X-Forwarded-For` or `request.remote_addr` +**`ACTION_EXPORT` coverage:** Both `/reports/export/inspections` and `/reports/export/issues` call `log_action(ACTION_EXPORT, ...)` before streaming the CSV response. + +**Logging coverage:** All route modules now import `logging` and define a module-level `logger = logging.getLogger(__name__)`. Previously missing from `facilities.py`, `templates.py`, `reports.py`, and `dashboard.py`. + --- ## 13. PDF Export @@ -460,16 +471,20 @@ Initialised in `app/__init__.py` as a module-level extension: ```python limiter = Limiter( - key_func = get_remote_address, + key_func = get_remote_address, default_limits = [], - storage_uri = 'memory://', # ← swap for 'redis://...' in multi-worker + storage_uri = os.environ.get('REDIS_URL', 'memory://'), ) limiter.init_app(app) ``` Import in routes: `from app import limiter`, then `@limiter.limit('N per period')`. -> **Multi-worker caveat:** `memory://` is per-process. With multiple Gunicorn workers the effective limit is `N × workers`. Use Redis for shared-state enforcement. +**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0` in the server environment. Counters are then shared across all Gunicorn workers and rate limits are correctly enforced. + +**Development:** `REDIS_URL` absent → falls back to `memory://` (in-process, single-worker). No Redis install required locally. + +> **`redis` package** is in `requirements.txt`. Install it with `pip install -r requirements.txt` before setting `REDIS_URL`. --- @@ -505,6 +520,8 @@ obj = db.session.get(Model, id) if obj is None: abort(404) ``` +> All `Model.query.get()` calls have been replaced — including `load_user()` in `models/user.py` and three calls in `utils/notifications.py`. + --- ## 17. Frontend Conventions @@ -599,10 +616,15 @@ Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log` | 14 | **Email in background thread** | Never block HTTP response | | 15 | **Open-redirect guards** | `_safe_next()` in auth.py; `_safe_referrer()` in customers.py | | 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.1** | Use `information_schema` — see phase12 | -| 17 | **Flask-Limiter `memory://` is per-process** | Multi-worker needs Redis for shared counters | +| 17 | **Set `REDIS_URL` in production** | `memory://` is per-process; multi-worker Gunicorn needs Redis for accurate 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 | +| 21 | **`supervisor` removed from Python-side ENUM** | Phase 11 migration complete — both DB and model ENUM must stay in sync | +| 22 | **SLA filter loads full result set (no paginate)** | SLA status is computed in Python; `_SLAFilteredPage` wrapper satisfies the template pagination interface | +| 23 | **`hmac.compare_digest()` for token comparison** | Prevents timing oracle attacks on `verify_set_password_token` | +| 24 | **`get_customer_scope()` uses bulk project query** | Single `Facility.project_id.in_(project_ids)` replaces per-assignment loop | +| 25 | **CSV exports always call `log_action(ACTION_EXPORT, ...)`** | Data exports are compliance-relevant audit events | --- diff --git a/README.md b/README.md index 2fa7c79..87a2830 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A production-grade web application for managing janitorial service contracts, fa | ORM / Migrations | SQLAlchemy + Alembic | | Auth (web) | Flask-Login + Flask-WTF (CSRF) | | Auth (API) | JWT + opaque refresh tokens | -| Rate limiting | Flask-Limiter | +| Rate limiting | Flask-Limiter (Redis-backed in production; in-process fallback for dev) | | Email | Flask-Mail (SMTP) | | PDF | ReportLab | | Frontend | Bootstrap 5, Chart.js, Jinja2 | @@ -88,6 +88,9 @@ MAIL_DEFAULT_SENDER=noreply@example.com # Application APP_BASE_URL=https://your-domain.com DIGEST_SECRET= + +# Rate limiting (optional — recommended for production multi-worker deployments) +# REDIS_URL=redis://127.0.0.1:6379/0 ``` > **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. @@ -118,7 +121,7 @@ gunicorn -c gunicorn_config.py wsgi:app The included `gunicorn_config.py` binds to `127.0.0.1:8000` with sync workers. Log files are written to `/home/jqc/logs/`. -> **Rate limiting note:** Flask-Limiter uses in-process memory storage by default. With multiple Gunicorn workers each process maintains its own counter. For accurate shared-state enforcement across workers, set `storage_uri = 'redis://localhost:6379'` in `app/__init__.py` and install Redis. +> **Rate limiting:** Flask-Limiter uses Redis when `REDIS_URL` is set in the environment, falling back to in-process memory for local development. In production with multiple Gunicorn workers, set `REDIS_URL=redis://127.0.0.1:6379/0` so rate-limit counters are shared across all workers. The `redis` package is included in `requirements.txt`. ### Nginx (recommended configuration) @@ -286,6 +289,7 @@ flask db downgrade | `APP_BASE_URL` | `""` | Base URL for links in emails | | `DIGEST_SECRET` | — | Authenticates all cron endpoints | | `MAX_CONTENT_LENGTH` | `50MB` | Maximum upload size per request | +| `REDIS_URL` | — | Redis connection URI for shared rate-limit storage; optional but recommended in production | ---