From 2c1d9f5e7795b79d9d2a80196599920be68fe64a Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 6 Jul 2026 17:18:29 -0400 Subject: [PATCH] July 6 - Optimize codes --- CLAUDE.md | 58 +++++++++++++++++-- app/__init__.py | 12 +++- app/api/__init__.py | 3 - app/api/devices.py | 95 ------------------------------- app/models/__init__.py | 3 +- app/models/device_registration.py | 24 -------- 6 files changed, 64 insertions(+), 131 deletions(-) delete mode 100644 app/api/devices.py delete mode 100644 app/models/device_registration.py diff --git a/CLAUDE.md b/CLAUDE.md index 72831d9..66ddef5 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:** June 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts) +> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix) --- @@ -133,8 +133,13 @@ lt_janitorial_quality_control/ │ └── utils/ ├── migrations/ │ └── versions/ -│ └── phase23_support_tickets.py ← HEAD +│ └── phase32_device_token_columns.py ← HEAD └── ... + +Note: `app/routes/broadcast.py` + `app/models/broadcast.py` (admin broadcasts) and +`app/routes/devices.py` (admin device registry, reads `api_device_tokens`) are also +part of the tree — see §7. Device registration on the API side lives in +`app/api/auth.py` only (there is no `app/api/devices.py`). ``` --- @@ -317,10 +322,22 @@ audit_logs: id, user_id (nullable), username (snapshot), user_role (snapshot), ``` 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 +api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, + ios_version, registered_at, last_seen_at ← ios_version + last_seen_at added phase32 UniqueConstraint(user_id, device_id) ``` +`api_device_tokens` is the **single** source of truth for device tracking. It is upserted by `POST /api/v1/devices/register` (in `app/api/auth.py`) on every app foreground and read by the admin Devices page (`/admin/devices`). The earlier `device_registrations` table / `DeviceRegistration` model was removed — see §17 phase30–32. + +### Broadcast + +``` +broadcasts: id, title VARCHAR(255), body TEXT, target_roles (JSON list of role strings), + sent_by_id (FK→users SET NULL), sent_at DATETIME, recipient_count INT +``` + +Admin-authored broadcast messages. Sending a broadcast fans out one `Notification` row per targeted user; the iPad picks them up through its existing `GET /api/v1/notifications?since=...` poll — **no dedicated broadcast API endpoint exists**. `recipient_count` snapshots how many notifications were created. Managed at `/admin/broadcast` (see §7 `broadcast` blueprint). + --- ## 6. Role & Permission Matrix @@ -374,6 +391,8 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | | `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | +| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) | +| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) | | `api` | `/api/v1` | parent blueprint | | `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` | | `api_facilities` | `/api/v1` | `/facilities`, `/facilities//areas` | @@ -649,7 +668,12 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase24_notify_defaults → phase25_inspection_gps → phase26_issue_vendor - → phase27_score_alerts ← HEAD + → phase27_score_alerts + → phase28_fix_inspection_notify + → phase29_broadcasts + → phase30_device_registry + → phase31_device_registry + → phase32_device_token_columns ← HEAD ``` ### phase21_performance_indexes @@ -706,7 +730,25 @@ Displayed in `issues/view.html` and editable via `IssueForm` (`form.html`). Staf Creates `facility_score_alerts` table. Used by `send_score_alerts()` in `sla.py` for 24-hour deduplication of score-drop notifications. Uses table existence check — safe to re-run. -**Deploy order for phases 24–27:** +### phase28_fix_inspection_notify + +Data-only migration. Resets the `notification_matrix` rows for `('inspection_completed', 'director')`, `('inspection_completed', 'admin')`, and `('inspection_completed', 'customer')` to `enabled=True`, matching `MATRIX_DEFAULTS`. These had been inadvertently disabled (likely via an accidental checkbox save on the matrix page), suppressing inspection-completed notifications from both web and mobile API. No schema change. + +### phase29_broadcasts + +Creates the `broadcasts` table backing the admin broadcast feature (see §5 `Broadcast` and §7 `broadcast` blueprint). Uses table existence check — safe to re-run. + +### phase30–32_device_registry (consolidation — read as a unit) + +These three migrations are the history of a **false start** in device tracking. Net effect after all three: the app tracks devices exclusively in **`api_device_tokens`** (`DeviceToken` model); the short-lived `device_registrations` table and its `DeviceRegistration` model no longer exist. + +- **phase30_device_registry** — created a separate `device_registrations` table (the abandoned approach). +- **phase31_device_registry** — drops `device_registrations` (`DROP TABLE IF EXISTS`). +- **phase32_device_token_columns** — the live path. Adds `ios_version VARCHAR(20)` and `last_seen_at DATETIME` to `api_device_tokens` (phase31's ALTERs were recorded-but-never-executed, so phase32 re-applies them via `INFORMATION_SCHEMA` existence checks) and drops the orphaned `device_registrations` table if still present. Safe to re-run. + +**The dead `DeviceRegistration` model, `app/api/devices.py` endpoint, and `api_devices` blueprint were removed (July 2026).** They defined a *second* `POST /api/v1/devices/register` that was shadowed at routing time by the `api_auth` copy and would have crashed anyway (it queried the dropped `device_registrations` table). Device registration now has a single implementation: `register_device()` in `app/api/auth.py`, writing to `api_device_tokens`. Do not reintroduce a competing device model or a duplicate register route. + +**Deploy order for phases 24–32:** ```bash flask db upgrade sudo systemctl restart gunicorn @@ -946,6 +988,10 @@ timeout = 30 - `client_max_body_size 50M` - Passes `X-Forwarded-For` +### ProxyFix (reverse-proxy awareness) + +`create_app()` wraps `app.wsgi_app` in `werkzeug.middleware.proxy_fix.ProxyFix(x_for=1, x_proto=1, x_host=1)`. Nginx terminates TLS and forwards over loopback, so without this every request's `remote_addr` is `127.0.0.1`. That would collapse all Flask-Limiter keys into one shared bucket (rate limits become global instead of per-client) and make `url_for(_external=True)` emit `http://` links. `x_for=1` trusts exactly one proxy hop (our own Nginx) — do not increase it unless another trusted proxy is added in front, or clients can spoof `X-Forwarded-For` and defeat rate limiting. + ### Recommended Cron Schedule ```bash 0 7 * * * curl -s -X POST https://your-domain.com/notifications/send-digest \ @@ -1026,6 +1072,8 @@ timeout = 30 | 68 | **Support ticket customer replies revert status from `answered` → `open`** | When a customer posts a follow-up on an answered ticket, the route sets `ticket.status = 'open'` so admins see it in their open queue. Admin must manually close or re-answer. | | 69 | **Customer issue create: `assigned_to` field hidden, `facility_id` scoped to `get_customer_scope()`** | `issues.create()` detects `role == 'customer'`, scopes facilities to the customer's assigned set, sets `staff = []` for the assigned_to dropdown, and hides the field in `form.html`. `IssueForm.facility_id.choices` must still include all active facilities so POST validation passes. | | 70 | **`notify()` does NOT commit — caller must `db.session.commit()` after all `notify()` calls** | `notify()` adds a `Notification` row to the session but leaves the commit to the caller. The support helpers (`_notify_admins_new_ticket`, `_notify_customer_reply`, `_notify_admins_customer_reply`) each call `db.session.commit()` after the `notify()` loop. | +| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. | +| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. | --- diff --git a/app/__init__.py b/app/__init__.py index cd14e19..b14a753 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,6 +6,7 @@ from flask_mail import Mail from flask_wtf.csrf import CSRFProtect from flask_limiter import Limiter from flask_limiter.util import get_remote_address +from werkzeug.middleware.proxy_fix import ProxyFix from config import config import os import logging @@ -30,6 +31,15 @@ def create_app(config_name='default'): app = Flask(__name__) app.config.from_object(config[config_name]) + # ── Reverse-proxy awareness (Nginx) ────────────────────────────────────── + # Nginx terminates TLS and forwards requests over the loopback interface, + # setting X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host. Without + # ProxyFix, request.remote_addr is always 127.0.0.1, which collapses every + # Flask-Limiter key into a single shared bucket (rate limits become global + # instead of per-client) and makes url_for(_external) emit http:// links. + # x_for=1 trusts exactly one proxy hop — our own Nginx. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + db.init_app(app) login_manager.init_app(app) migrate.init_app(app, db) @@ -199,7 +209,6 @@ def create_app(config_name='default'): from app.api.notifications import bp as _api_notifications_bp from app.api.stats import bp as _api_stats_bp from app.api.comments import bp as _api_comments_bp - from app.api.devices import bp as _api_devices_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) @@ -209,7 +218,6 @@ def create_app(config_name='default'): csrf.exempt(_api_notifications_bp) csrf.exempt(_api_stats_bp) csrf.exempt(_api_comments_bp) - csrf.exempt(_api_devices_bp) register_api(app) # ── Security response headers ───────────────────────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index e59e773..178697c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -50,7 +50,4 @@ def register_api(app): from app.api.comments import bp as comments_bp api_bp.register_blueprint(comments_bp) - from app.api.devices import bp as devices_bp - api_bp.register_blueprint(devices_bp) - app.register_blueprint(api_bp) \ No newline at end of file diff --git a/app/api/devices.py b/app/api/devices.py deleted file mode 100644 index 04434b8..0000000 --- a/app/api/devices.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -app/api/devices.py ------------------- -Mobile API endpoint for device registration. - -POST /api/v1/devices/register - Upserts a device record for the authenticated user. - Called on every app foreground (active scenePhase) so last_seen_at - stays current and the admin can identify stale / outdated installs. - - Request JSON - ------------ - { - "device_id": "stable-uuid-from-keychain", // required - "device_name": "Nguyen's iPad", // UIDevice.current.name - "app_version": "1.2.0", // CFBundleShortVersionString - "ios_version": "18.3.1" // UIDevice.current.systemVersion - } - - Response 200 - ------------ - { "ok": true, "data": { "registered": true } } -""" - -import logging - -from flask import Blueprint, request, g -from app import db -from app.models.device_registration import DeviceRegistration -from app.api.errors import api_ok, api_error -from app.api.decorators import jwt_required -from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE -from app.utils.time_utils import now_eastern - -logger = logging.getLogger(__name__) - -bp = Blueprint('api_devices', __name__) - -_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} - - -@bp.route('/devices/register', methods=['POST']) -@jwt_required -def register_device(): - user = g.api_user - if user.role not in _ALLOWED_ROLES: - return api_error('Access denied', 403) - - data = request.get_json(silent=True) or {} - - device_id = (data.get('device_id') or '').strip() - device_name = (data.get('device_name') or '').strip()[:255] - app_version = (data.get('app_version') or '').strip()[:32] - ios_version = (data.get('ios_version') or '').strip()[:32] - - if not device_id: - return api_error('device_id is required', 400) - if len(device_id) > 64: - return api_error('device_id too long', 400) - - now = now_eastern() - - existing = DeviceRegistration.query.filter_by(device_id=device_id).first() - if existing: - # Update — always refresh last_seen_at and app/ios version - existing.user_id = user.id # re-bind if different user logs in same device - existing.device_name = device_name or existing.device_name - existing.app_version = app_version or existing.app_version - existing.ios_version = ios_version or existing.ios_version - existing.last_seen_at = now - db.session.commit() - log_action(ACTION_UPDATE, 'DeviceRegistration', existing.id, - f'{device_name} v{app_version}', - f'user={user.username}; ios={ios_version}') - logger.info('API DEVICES | updated | device_id=%s | user=%s | app=%s', - device_id[:8], user.username, app_version) - else: - reg = DeviceRegistration( - device_id = device_id, - user_id = user.id, - device_name = device_name, - app_version = app_version, - ios_version = ios_version, - registered_at = now, - last_seen_at = now, - ) - db.session.add(reg) - db.session.commit() - log_action(ACTION_CREATE, 'DeviceRegistration', reg.id, - f'{device_name} v{app_version}', - f'user={user.username}; ios={ios_version}') - logger.info('API DEVICES | registered | device_id=%s | user=%s | app=%s', - device_id[:8], user.username, app_version) - - return api_ok({'registered': True}) diff --git a/app/models/__init__.py b/app/models/__init__.py index 1e43a69..dd90aff 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,5 +5,4 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem, from app.models.issue import Issue from app.models.project import Project, CustomerAssignment from app.models.api_token import RefreshToken, DeviceToken -from app.models.notification_matrix import NotificationMatrix -from app.models.device_registration import DeviceRegistration \ No newline at end of file +from app.models.notification_matrix import NotificationMatrix \ No newline at end of file diff --git a/app/models/device_registration.py b/app/models/device_registration.py deleted file mode 100644 index 31238ea..0000000 --- a/app/models/device_registration.py +++ /dev/null @@ -1,24 +0,0 @@ -# app/models/device_registration.py -# ----------------------------------- -# Tracks iOS devices that have registered with the server. -# One row per physical device — upserted on every app foreground. - -from app import db -from app.utils.time_utils import now_eastern - - -class DeviceRegistration(db.Model): - __tablename__ = 'device_registrations' - - id = db.Column(db.Integer, primary_key=True) - # Stable UUID generated on first launch and stored in iOS Keychain. - # Unique across all devices; survives app restarts but not device wipes. - device_id = db.Column(db.String(64), nullable=False, unique=True, index=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - device_name = db.Column(db.String(255), nullable=False, default='') - app_version = db.Column(db.String(32), nullable=False, default='') - ios_version = db.Column(db.String(32), nullable=False, default='') - registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern) - last_seen_at = db.Column(db.DateTime, nullable=False, default=now_eastern) - - user = db.relationship('User', backref=db.backref('devices', lazy='dynamic'))