July 6 - Optimize codes
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Audience:** AI assistants and developers working on this codebase.
|
||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||
> **Last reviewed:** 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/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
||||
| `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/<id>/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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-2
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
@@ -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'))
|
||||
Reference in New Issue
Block a user