05/02 Update CLAUDE.md
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:** April 2026 (Phase 12 complete + post-review hardening + UX improvements)
|
||||
> **Last reviewed:** May 2026 (Phase B complete — iPad offline inspection app)
|
||||
|
||||
---
|
||||
|
||||
@@ -16,18 +16,19 @@
|
||||
6. [Role & Permission Matrix](#6-role--permission-matrix)
|
||||
7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory)
|
||||
8. [Utility Modules](#8-utility-modules)
|
||||
9. [Mobile API (Phase 7)](#9-mobile-api-phase-7)
|
||||
10. [Notification System](#10-notification-system)
|
||||
11. [SLA Engine](#11-sla-engine)
|
||||
12. [Audit Trail](#12-audit-trail)
|
||||
13. [PDF Export](#13-pdf-export)
|
||||
14. [Scheduled Reports](#14-scheduled-reports)
|
||||
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)
|
||||
9. [Mobile API (Phase 7 / Phase A / Phase B)](#9-mobile-api-phase-7--phase-a--phase-b)
|
||||
10. [iPad Native App](#10-ipad-native-app)
|
||||
11. [Notification System](#11-notification-system)
|
||||
12. [SLA Engine](#12-sla-engine)
|
||||
13. [Audit Trail](#13-audit-trail)
|
||||
14. [PDF Export](#14-pdf-export)
|
||||
15. [Scheduled Reports](#15-scheduled-reports)
|
||||
16. [Rate Limiting](#16-rate-limiting)
|
||||
17. [Alembic Migration Chain](#17-alembic-migration-chain)
|
||||
18. [Frontend Conventions](#18-frontend-conventions)
|
||||
19. [Infrastructure](#19-infrastructure)
|
||||
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
||||
21. [Change Philosophy](#21-change-philosophy)
|
||||
|
||||
---
|
||||
|
||||
@@ -42,7 +43,8 @@
|
||||
- **Notification** system (in-app + email) driven by an admin-controlled matrix
|
||||
- **Reports** — on-demand PDF/CSV scorecards and scheduled email digests
|
||||
- **Audit trail** — immutable log of every create/update/delete action
|
||||
- **Mobile API** — JWT-authenticated REST layer (Phase 7) for a React Native / Expo mobile app
|
||||
- **Mobile API** — JWT-authenticated REST layer for the iPad native app
|
||||
- **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B complete)
|
||||
|
||||
The application is actively deployed in production and maintained by a single developer/administrator.
|
||||
|
||||
@@ -67,7 +69,9 @@ The application is actively deployed in production and maintained by a single de
|
||||
| Frontend | Bootstrap 5, Chart.js, vanilla JS |
|
||||
| Server | Gunicorn (sync workers) behind Nginx |
|
||||
| OS | Ubuntu Linux |
|
||||
| Mobile client | React Native + Expo (Expo Go for dev; cloud Mac for iOS builds) |
|
||||
| **iPad app** | **SwiftUI + SwiftData, iOS 17+, Xcode 15+** |
|
||||
| **iPad networking** | **URLSession async/await + NWPathMonitor** |
|
||||
| **iPad auth storage** | **iOS Keychain (Security.framework)** |
|
||||
| Timezone | All datetimes stored as US/Eastern (naive, via `now_eastern()`) |
|
||||
|
||||
---
|
||||
@@ -78,54 +82,25 @@ The application is actively deployed in production and maintained by a single de
|
||||
lt_janitorial_quality_control/
|
||||
├── 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)
|
||||
│ ├── api/ # Mobile REST API
|
||||
│ │ ├── __init__.py # api_bp parent blueprint + register_api()
|
||||
│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*; rate-limited login/refresh
|
||||
│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*
|
||||
│ │ ├── facilities.py # /api/v1/facilities/* (Phase A)
|
||||
│ │ ├── templates.py # /api/v1/templates/* (Phase A)
|
||||
│ │ ├── inspections.py # /api/v1/inspections/* (Phase B)
|
||||
│ │ ├── issues.py # /api/v1/issues/* (Phase B)
|
||||
│ │ ├── photos.py # /api/v1/photos/upload (Phase B)
|
||||
│ │ ├── decorators.py # @jwt_required
|
||||
│ │ ├── errors.py # JSON error helpers + error handler registration
|
||||
│ │ └── jwt_utils.py # generate_access_token()
|
||||
│ ├── 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
|
||||
│ │ ├── project.py # Project, CustomerAssignment
|
||||
│ │ ├── scheduled_report.py # ScheduledReport
|
||||
│ │ └── user.py # User (password-setup workflow, display_name)
|
||||
│ ├── routes/
|
||||
│ │ ├── 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/* — 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/*
|
||||
│ │ └── audit.py # /audit/*
|
||||
│ │ ├── inspection.py # Inspection now has mobile_local_id column (Phase B)
|
||||
│ │ ├── issue.py # Issue now has mobile_local_id column (Phase B)
|
||||
│ │ └── ... # (all other models unchanged)
|
||||
│ ├── routes/ # (unchanged from Phase 12)
|
||||
│ ├── static/
|
||||
│ │ ├── css/ipad_responsive.css
|
||||
│ │ └── uploads/
|
||||
│ ├── templates/
|
||||
│ │ ├── base.html # Active nav tab styling; request.endpoint-based active detection
|
||||
│ │ ├── _sla_badge.html
|
||||
│ │ └── ...
|
||||
│ └── utils/
|
||||
│ ├── audit.py # log_action() + ACTION_* constants
|
||||
│ ├── decorators.py # @admin_required, @supervisor_required, @project_manager_required, @customer_required
|
||||
│ ├── forms.py # All WTForms (AreaForm includes 'floor' type)
|
||||
│ ├── notifications.py # notify(), notify_by_matrix(), send_pending_digests()
|
||||
│ ├── 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/
|
||||
│ └── versions/
|
||||
│ ├── phase1_projects_roles.py
|
||||
@@ -135,10 +110,45 @@ lt_janitorial_quality_control/
|
||||
│ ├── phase9_user_full_name.py
|
||||
│ ├── phase10_customer_password_setup.py
|
||||
│ ├── phase11_director_role.py
|
||||
│ └── phase12_performance_indexes.py ← HEAD
|
||||
│ ├── phase12_performance_indexes.py
|
||||
│ └── phase_b_mobile_local_id.py ← HEAD
|
||||
├── JanitorialQC/ # Xcode iOS project root
|
||||
│ ├── JanitorialQC.xcodeproj
|
||||
│ └── JanitorialQC/
|
||||
│ ├── JQCApp.swift # @main — SwiftData container, env objects
|
||||
│ ├── ContentView.swift # Auth gate: LoginView ↔ DashboardView
|
||||
│ ├── Auth/
|
||||
│ │ ├── AuthManager.swift # Login/logout/restore session, Keychain persistence
|
||||
│ │ └── KeychainHelper.swift # Security.framework wrapper
|
||||
│ ├── API/
|
||||
│ │ ├── APIClient.swift # URLSession + JWT inject + 401 retry + photo upload
|
||||
│ │ └── APIModels.swift # Codable response DTOs
|
||||
│ ├── Sync/
|
||||
│ │ └── SyncManager.swift # NWPathMonitor + outbox queue processor
|
||||
│ ├── Models/ # SwiftData local models
|
||||
│ │ ├── LocalFacility.swift
|
||||
│ │ ├── LocalArea.swift
|
||||
│ │ ├── LocalTemplate.swift
|
||||
│ │ ├── LocalInspection.swift
|
||||
│ │ ├── LocalIssue.swift
|
||||
│ │ ├── PendingPhoto.swift
|
||||
│ │ └── SyncQueueEntry.swift
|
||||
│ ├── Views/
|
||||
│ │ ├── Auth/
|
||||
│ │ │ └── LoginView.swift
|
||||
│ │ ├── Dashboard/
|
||||
│ │ │ └── DashboardView.swift # Sidebar + all detail views
|
||||
│ │ └── Inspection/
|
||||
│ │ ├── StartInspectionView.swift
|
||||
│ │ ├── ExecuteInspectionView.swift
|
||||
│ │ ├── FlagIssueView.swift
|
||||
│ │ └── FormRenderer/
|
||||
│ │ └── FormFieldView.swift # All field type renderers
|
||||
│ └── Utils/
|
||||
│ └── Constants.swift # baseURL, Keychain key strings
|
||||
├── config.py
|
||||
├── gunicorn_config.py
|
||||
├── requirements.txt # Includes Flask-Limiter
|
||||
├── requirements.txt
|
||||
├── run.py
|
||||
└── wsgi.py
|
||||
```
|
||||
@@ -159,8 +169,8 @@ lt_janitorial_quality_control/
|
||||
| `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` | 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. |
|
||||
| `DIGEST_SECRET` | Authenticates all cron endpoints |
|
||||
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
||||
|
||||
### Email SSL Auto-Detection
|
||||
|
||||
@@ -191,13 +201,7 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
|
||||
|
||||
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`
|
||||
|
||||
**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.
|
||||
|
||||
**Customer self-service setup:** When a customer clicks their invitation link, `set_password.html` prompts them to choose their own **username** (replacing the auto-generated placeholder) and set a **password**. The `set_password` route saves `form.username.data` to `user.username` before committing. The `SetPasswordForm` validates username uniqueness inline via `validate_username()`.
|
||||
|
||||
**Token verification:** `User.verify_set_password_token()` guards in order: token present → DB lookup → `password_set=False` check → expiry check → `hmac.compare_digest()` constant-time comparison. The `password_set=False` guard ensures accounts already activated cannot be re-used via a stale token. `compare_digest` is wrapped in `try/except` to safely handle type mismatches.
|
||||
**Key property:** `display_name` → `full_name.strip()` or falls back to `username`.
|
||||
|
||||
### Facility / Area
|
||||
|
||||
@@ -216,42 +220,30 @@ customer_assignments: id, user_id, project_id, facility_id (nullable)
|
||||
UniqueConstraint(user_id, project_id, facility_id)
|
||||
```
|
||||
|
||||
**UI terminology:** "Project" is displayed as **"Contract"** throughout the UI. All backend identifiers (`project_id`, class `Project`, routes `projects.*`) are unchanged.
|
||||
|
||||
`facility_id = NULL` → access to all active facilities in that project.
|
||||
|
||||
### Inspection
|
||||
|
||||
```
|
||||
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
|
||||
completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note,
|
||||
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
|
||||
```
|
||||
|
||||
**Indexed columns (Phase 12):** `status`, `facility_id`, `inspector_id`, `inspection_date`
|
||||
**`mobile_local_id`:** UUID string generated on the iPad. Used for idempotency — if a submission arrives twice (network retry), the server returns the existing record without creating a duplicate. Set `NULL` for all web-created inspections.
|
||||
|
||||
**Score rule:** Items with `score = 0` mean "unanswered" — excluded from calculation entirely.
|
||||
|
||||
**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, reported_at, resolved_at, result_notes, result_photos (JSON),
|
||||
verified_by, verified_at, verification_note, sla_notified
|
||||
verified_by, verified_at, verification_note, sla_notified,
|
||||
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
|
||||
```
|
||||
|
||||
**Indexed columns (Phase 12):** `status`, `severity`, `assigned_to`, `reported_at`
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**`mobile_local_id`:** Same idempotency pattern as `inspections.mobile_local_id`.
|
||||
|
||||
### Notification / NotificationPreference
|
||||
|
||||
@@ -260,8 +252,6 @@ notifications: id, user_id, title, body, link, is_read, created_at, issue_id, ev
|
||||
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
|
||||
|
||||
```
|
||||
@@ -272,7 +262,7 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||||
### AuditLog
|
||||
|
||||
```
|
||||
audit_logs: id, user_id (nullable, SET NULL on delete), username (snapshot), user_role (snapshot),
|
||||
audit_logs: id, user_id (nullable), username (snapshot), user_role (snapshot),
|
||||
action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address
|
||||
```
|
||||
|
||||
@@ -285,10 +275,6 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
|
||||
UniqueConstraint(user_id, device_id)
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## 6. Role & Permission Matrix
|
||||
@@ -326,21 +312,25 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
|
||||
|
||||
| Blueprint | Prefix | Notable routes |
|
||||
|---|---|---|
|
||||
| `auth` | `/auth` | `/login` (rate-limited 20/min), `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||
| `facilities` | `/facilities` | CRUD + area management; list grouped by Contract with collapsible sections; detail page has Back button |
|
||||
| `facilities` | `/facilities` | CRUD + area management |
|
||||
| `projects` | `/projects` | CRUD + customer assignment management |
|
||||
| `customers` | `/customers` | list (expired invitation banner), invite (name + email only), set-password (customer chooses username + password), manage, import CSV |
|
||||
| `inspections` | `/inspections` | list (stale badge, `now` passed from route), start (template + facility only — area and notes removed), execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect |
|
||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||
| `inspections` | `/inspections` | list, 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 (SLA column, facility filter, quick-assign dropdown), view, create, update, verify, comment, follow/unfollow, verification queue, delete |
|
||||
| `issues` | `/issues` | `POST /<id>/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) |
|
||||
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, delete, quick-assign |
|
||||
| `notifications` | `/notifications` | list, mark-read, preferences, 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` | `/api/v1` | parent blueprint |
|
||||
| `api_auth` | `/api/v1` | `/auth/login` (10/min), `/auth/refresh` (30/min), `/auth/logout`, `/auth/me`, `/devices/register` |
|
||||
| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` |
|
||||
| `api_facilities` | `/api/v1` | `/facilities`, `/facilities/<id>/areas` |
|
||||
| `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` |
|
||||
| `api_inspections` | `/api/v1` | `POST /inspections`, `PATCH /inspections/<id>` |
|
||||
| `api_issues` | `/api/v1` | `POST /issues` |
|
||||
| `api_photos` | `/api/v1` | `POST /photos/upload` |
|
||||
|
||||
---
|
||||
|
||||
@@ -353,7 +343,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. Uses a single bulk `Facility.project_id.in_(...)` query for project-scoped assignments — never N+1.
|
||||
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff.
|
||||
|
||||
### `forms.py`
|
||||
All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role.
|
||||
@@ -363,14 +353,36 @@ All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes
|
||||
|
||||
### `sla.py`
|
||||
`sla_status(issue)` → `'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
|
||||
`send_sla_alerts()` — called by cron, deduplicates via `issue.sla_notified`.
|
||||
|
||||
### `pdf_export.py`
|
||||
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.
|
||||
|
||||
---
|
||||
|
||||
## 9. Mobile API (Phase 7)
|
||||
## 9. Mobile API (Phase 7 / Phase A / Phase B)
|
||||
|
||||
### CSRF Exemption Pattern — Critical
|
||||
|
||||
**`csrf.exempt(api_bp)` does NOT cascade to sub-blueprints.** Flask-WTF's `_is_exempt()` checks the leaf blueprint object. Each child blueprint must be exempted individually in `app/__init__.py`:
|
||||
|
||||
```python
|
||||
from app.api import register_api, api_bp
|
||||
from app.api.auth import bp as _api_auth_bp
|
||||
from app.api.facilities import bp as _api_facilities_bp
|
||||
from app.api.templates import bp as _api_templates_bp
|
||||
from app.api.inspections import bp as _api_inspections_bp
|
||||
from app.api.issues import bp as _api_issues_bp
|
||||
from app.api.photos import bp as _api_photos_bp
|
||||
csrf.exempt(_api_auth_bp)
|
||||
csrf.exempt(_api_facilities_bp)
|
||||
csrf.exempt(_api_templates_bp)
|
||||
csrf.exempt(_api_inspections_bp)
|
||||
csrf.exempt(_api_issues_bp)
|
||||
csrf.exempt(_api_photos_bp)
|
||||
register_api(app)
|
||||
```
|
||||
|
||||
**Every new Phase C+ blueprint must add its own `csrf.exempt()` line here before `register_api(app)`.** Failing to do so produces a `"The CSRF token is missing."` error on all POST requests to that blueprint.
|
||||
|
||||
### Auth Flow
|
||||
1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex)
|
||||
@@ -383,25 +395,156 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
|
||||
|---|---|
|
||||
| `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 |
|
||||
|
||||
### 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`
|
||||
### Phase A Endpoints
|
||||
|
||||
| Endpoint | Auth | Description |
|
||||
|---|---|---|
|
||||
| `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user |
|
||||
| `GET /api/v1/facilities/<id>/areas` | jwt_required | Areas for a facility |
|
||||
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) |
|
||||
| `GET /api/v1/templates/<id>` | jwt_required | Full template with form_schema |
|
||||
|
||||
Customer role is blocked from template endpoints (`_ALLOWED_ROLES` check). Facility endpoints honour `get_customer_scope()`.
|
||||
|
||||
### Phase B Endpoints
|
||||
|
||||
| Endpoint | Auth | Description |
|
||||
|---|---|---|
|
||||
| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` |
|
||||
| `PATCH /api/v1/inspections/<id>` | jwt_required | Update inspection (draft → completed) |
|
||||
| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id` |
|
||||
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` |
|
||||
|
||||
### Idempotency Pattern
|
||||
|
||||
All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt:
|
||||
|
||||
### CSRF Exemption Pattern
|
||||
```python
|
||||
csrf.exempt(api_bp) # BEFORE register_api(app) — order matters
|
||||
register_api(app)
|
||||
existing = Model.query.filter_by(mobile_local_id=mobile_local_id).first()
|
||||
if existing:
|
||||
return api_ok({'id': existing.id, 'duplicate': True})
|
||||
```
|
||||
|
||||
### Phase 2 (planned)
|
||||
`api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos` — all stubbed in `app/api/__init__.py`.
|
||||
This protects against double-submission when the network fails after the server commits but before the device receives the response. Web-created records have `mobile_local_id = NULL`.
|
||||
|
||||
### Photo Upload Flow
|
||||
|
||||
Photos are uploaded **before** the inspection or issue is submitted:
|
||||
1. iPad calls `POST /api/v1/photos/upload` with multipart image
|
||||
2. Server saves to `app/static/uploads/inspection_photos/` or `issue_photos/`
|
||||
3. Returns `{ "server_path": "uploads/inspection_photos/uuid.jpg" }`
|
||||
4. iPad includes `server_path` in the subsequent inspection/issue POST
|
||||
|
||||
### Score Calculation (Server-Side)
|
||||
|
||||
`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly:
|
||||
- Rating value `0` = unanswered → excluded from total
|
||||
- pass_fail accepted values: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant`
|
||||
- Returns `float` 0–100 or `None` if no scoreable fields
|
||||
|
||||
---
|
||||
|
||||
## 10. Notification System
|
||||
## 10. iPad Native App
|
||||
|
||||
### Platform
|
||||
|
||||
- **Language:** Swift 5.10+
|
||||
- **UI:** SwiftUI (iPad-only, all four orientations)
|
||||
- **Local DB:** SwiftData (iOS 17+ required)
|
||||
- **Networking:** URLSession async/await
|
||||
- **Connectivity:** NWPathMonitor (Network.framework)
|
||||
- **Token storage:** iOS Keychain (Security.framework)
|
||||
- **Xcode:** 15+
|
||||
|
||||
### Offline-First Architecture
|
||||
|
||||
The app follows the **outbox pattern** — every inspector action writes to SwiftData first; the server is a secondary destination.
|
||||
|
||||
```
|
||||
Inspector action → SwiftData write (always succeeds) → SyncQueue entry
|
||||
↓
|
||||
NWPathMonitor detects reconnect
|
||||
↓
|
||||
SyncManager.triggerSync()
|
||||
1. Upload pending photos
|
||||
2. Submit completed inspections
|
||||
3. Submit pending issues
|
||||
4. Pull fresh reference data
|
||||
```
|
||||
|
||||
### SwiftData Models
|
||||
|
||||
| Model | Purpose |
|
||||
|---|---|
|
||||
| `LocalFacility` | Cached facility reference data (read-only on device) |
|
||||
| `LocalArea` | Cached area reference data |
|
||||
| `LocalTemplate` | Cached template + `formSchemaJSON` (raw JSON string) |
|
||||
| `LocalInspection` | Inspector-created inspection records |
|
||||
| `LocalIssue` | Issues flagged during inspections |
|
||||
| `PendingPhoto` | Photos awaiting upload; tracks `localFilePath` → `serverPath` |
|
||||
| `SyncQueueEntry` | Outbox queue (currently unused directly — filtering done in Swift) |
|
||||
|
||||
### LocalInspection Status Flow
|
||||
|
||||
```
|
||||
"draft" → "completed" → "synced"
|
||||
→ "failed" (after 5 retries)
|
||||
```
|
||||
|
||||
`syncStatus` is separate from `status`:
|
||||
- `status`: inspector workflow state
|
||||
- `syncStatus`: server submission state (`"pending"` | `"synced"` | `"failed"`)
|
||||
|
||||
### SyncManager Key Behaviours
|
||||
|
||||
- **Fetch-then-filter pattern:** All `processPhotoQueue`, `processInspectionQueue`, `processIssueQueue` fetch all records and filter in Swift rather than using `#Predicate` with string literals. This avoids a SwiftData `#Predicate` macro type-inference bug with string comparisons across model boundaries.
|
||||
- **Sequential reference data fetch:** `pullReferenceData()` uses sequential `await` (not `async let`) to avoid Swift 6 actor-isolation warnings on `Decodable` structs.
|
||||
- **Photo-before-inspection ordering:** `processPhotoQueue` runs before `processInspectionQueue`. An inspection is only submitted after all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"failed"`.
|
||||
- **Retry limit:** 5 retries per item before marking `syncStatus = "failed"`.
|
||||
|
||||
### APIClient Key Behaviours
|
||||
|
||||
- **401 auto-retry:** On a 401 response, `refreshAccessToken()` is called once and the original request is retried. If refresh fails, `APIError.notAuthenticated` is thrown.
|
||||
- **Keychain token storage:** `kSecAttrAccessibleAfterFirstUnlock` — tokens survive device reboot, accessible for background sync.
|
||||
- **Photo upload:** Multipart `form-data` built manually (no third-party library). Boundary is a UUID string.
|
||||
|
||||
### FormFieldView — Supported Field Types
|
||||
|
||||
All types from the web app's `INPUT_FIELD_TYPES` set are rendered:
|
||||
|
||||
| Type | SwiftUI renderer |
|
||||
|---|---|
|
||||
| `text`, `email` | `TextField` |
|
||||
| `textarea` | `TextEditor` |
|
||||
| `number` | `TextField` + `.decimalPad` |
|
||||
| `date` | `DatePicker` |
|
||||
| `checkbox` | `Toggle` |
|
||||
| `checkbox_group` | Custom multi-select buttons |
|
||||
| `radio` | Custom radio buttons |
|
||||
| `select` | `Picker(.menu)` |
|
||||
| `rating` | Custom star rating (tap same star to clear) |
|
||||
| `pass_fail` | Two-button Pass/Fail control |
|
||||
| `signature` | `PKCanvasView` (PencilKit) |
|
||||
| `image` | `UIImagePickerController` sheet → local file save |
|
||||
| `table` | `Grid` of `TextField` |
|
||||
| `section`, `label` | Display-only `Text` |
|
||||
|
||||
### Known iOS-Specific Constraints
|
||||
|
||||
| # | Constraint | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | **`import Combine` required for `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors |
|
||||
| 2 | **`NavigationSplitView` — no `selection:` binding** | `init(selection:content:)` unavailable on iPadOS 17; use `List` with manual `Button` + `@State var selectedTab` |
|
||||
| 3 | **`#Predicate` — no string literal comparisons across model boundaries** | SwiftData macro type-inference bug; fetch all + filter in Swift instead |
|
||||
| 4 | **`async let` — Swift 6 actor-isolation warnings on Decodable** | Use sequential `await` calls for reference data fetches |
|
||||
| 5 | **PencilKit requires framework linkage** | Add `PencilKit.framework` under Target → Frameworks, Libraries, and Embedded Content |
|
||||
| 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls |
|
||||
| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for background sync** | Tokens must be readable when the app is woken by BGTaskScheduler |
|
||||
|
||||
---
|
||||
|
||||
## 11. Notification System
|
||||
|
||||
### Event Constants (`app/models/notification.py`)
|
||||
```
|
||||
@@ -419,14 +562,9 @@ customer_inspection_completed
|
||||
| `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** on assignment/status/comment
|
||||
- Issue **followers** on any update
|
||||
- SLA **assignee + followers** on SLA events
|
||||
|
||||
---
|
||||
|
||||
## 11. SLA Engine
|
||||
## 12. SLA Engine
|
||||
|
||||
| Severity | Window | At-Risk |
|
||||
|---|---|---|
|
||||
@@ -435,41 +573,33 @@ customer_inspection_completed
|
||||
| medium | 72h | 54h |
|
||||
| low | 168h | 126h |
|
||||
|
||||
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.
|
||||
`issue.sla_notified` prevents duplicate cron notifications.
|
||||
|
||||
---
|
||||
|
||||
## 12. Audit Trail
|
||||
## 13. Audit Trail
|
||||
|
||||
- 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`
|
||||
|
||||
**`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`.
|
||||
- Mobile API routes call `log_action()` for all create/update operations
|
||||
- Immutable — never updated or deleted through the application
|
||||
|
||||
---
|
||||
|
||||
## 13. PDF Export
|
||||
## 14. PDF Export
|
||||
|
||||
ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF.
|
||||
|
||||
---
|
||||
|
||||
## 14. Scheduled Reports
|
||||
## 15. Scheduled Reports
|
||||
|
||||
Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`.
|
||||
Cron: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
|
||||
|
||||
---
|
||||
|
||||
## 15. Rate Limiting
|
||||
|
||||
Initialised in `app/__init__.py` as a module-level extension:
|
||||
## 16. Rate Limiting
|
||||
|
||||
```python
|
||||
limiter = Limiter(
|
||||
@@ -477,69 +607,57 @@ limiter = Limiter(
|
||||
default_limits = [],
|
||||
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')`.
|
||||
|
||||
**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`.
|
||||
**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`. Counters shared across all Gunicorn workers.
|
||||
|
||||
---
|
||||
|
||||
## 16. Alembic Migration Chain
|
||||
## 17. 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
|
||||
→ phase12_performance_indexes → phase_b_mobile_local_id ← HEAD
|
||||
```
|
||||
|
||||
### phase_b_mobile_local_id
|
||||
|
||||
Adds `mobile_local_id VARCHAR(64) NULL` + index to both `inspections` and `issues`.
|
||||
Uses `INFORMATION_SCHEMA.COLUMNS` and `INFORMATION_SCHEMA.STATISTICS` existence checks — safe to re-run.
|
||||
|
||||
### MySQL ENUM Change Protocol (3 steps — always follow)
|
||||
```sql
|
||||
-- 1. Expand to include both values
|
||||
-- 1. Expand
|
||||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL;
|
||||
-- 2. Migrate data
|
||||
-- 2. Migrate
|
||||
UPDATE users SET role = 'director' WHERE role = 'supervisor';
|
||||
-- 3. Contract to remove old value
|
||||
-- 3. Contract
|
||||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
|
||||
```
|
||||
|
||||
### 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.
|
||||
### MySQL Compatibility Rules
|
||||
|
||||
- **`CREATE INDEX IF NOT EXISTS`** — not supported on MySQL < 8.0.12. Always use `INFORMATION_SCHEMA.STATISTICS` check first.
|
||||
- **`batch_alter_table`** — SQLite-only workaround; do not use for MySQL migrations. Use direct `ALTER TABLE` statements.
|
||||
- **Migration deploy order:** Always run `flask db upgrade` with the **old** `app/__init__.py` still in place if the new version imports models that reference columns the migration would add. Swap `__init__.py` after the migration succeeds.
|
||||
|
||||
### Deprecated SQLAlchemy Patterns
|
||||
```python
|
||||
# WRONG
|
||||
Model.query.get(id)
|
||||
Model.query.get_or_404(id)
|
||||
|
||||
# CORRECT
|
||||
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
|
||||
## 18. Frontend Conventions
|
||||
|
||||
### 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.
|
||||
Detected via `request.endpoint.startswith('<blueprint>.')` in each nav `<a>` tag.
|
||||
|
||||
### Display Names
|
||||
Always use `user.display_name` in templates — never `.username` for display purposes.
|
||||
@@ -556,7 +674,6 @@ Always use `user.display_name` in templates — never `.username` for display pu
|
||||
|
||||
### 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
|
||||
|
||||
### Real-Time
|
||||
@@ -564,7 +681,7 @@ Always use `user.display_name` in templates — never `.username` for display pu
|
||||
|
||||
---
|
||||
|
||||
## 18. Infrastructure
|
||||
## 19. Infrastructure
|
||||
|
||||
### Gunicorn
|
||||
```python
|
||||
@@ -573,7 +690,6 @@ workers = multiprocessing.cpu_count() * 2 + 1
|
||||
worker_class = "sync"
|
||||
timeout = 30
|
||||
```
|
||||
Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
|
||||
|
||||
### Application Logging
|
||||
- `RotatingFileHandler` → `logs/jqc.log` (5 × 5 MB)
|
||||
@@ -598,7 +714,7 @@ Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
|
||||
|
||||
---
|
||||
|
||||
## 19. Known Constraints & Hard Rules
|
||||
## 20. Known Constraints & Hard Rules
|
||||
|
||||
| # | Rule | Rationale |
|
||||
|---|---|---|
|
||||
@@ -606,7 +722,7 @@ Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
|
||||
| 2 | **`now_eastern()` always** | `utcnow()` caused incorrect SLA cutoffs |
|
||||
| 3 | **3-step MySQL ENUM changes** | Skipping causes data loss |
|
||||
| 4 | **Port 465 → SSL; 587 → STARTTLS** | Both True breaks Flask-Mail |
|
||||
| 5 | **`CSRFProtect` as extension instance** | Flask-WTF 1.0+ removed per-route `@csrf_exempt` |
|
||||
| 5 | **`csrf.exempt()` on each child blueprint individually** | `csrf.exempt(api_bp)` does NOT cascade; Flask-WTF checks leaf blueprint object only |
|
||||
| 6 | **`supervisor_required` name preserved** | Renaming would touch 30+ route decorators |
|
||||
| 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 |
|
||||
@@ -617,24 +733,25 @@ Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
|
||||
| 13 | **Bulk queries in customer list** | Per-customer loops cause N+1 |
|
||||
| 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 | **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 |
|
||||
| 26 | **`StartInspectionForm` has no `area_id` or `notes` fields** | Both removed from the start page; `Inspection` constructor receives `area_id=None, notes=None` explicitly |
|
||||
| 27 | **Customer set-password page collects username + password** | `SetPasswordForm` includes `username` field; route saves it to `user.username`, replacing the auto-generated placeholder |
|
||||
| 28 | **`verify_set_password_token` requires `password_set=False`** | Prevents reuse of a stale token on an already-activated account |
|
||||
| 29 | **Facilities list grouped by Contract in route, not template** | `list_facilities()` builds a `grouped` OrderedDict before rendering; template iterates `grouped`, not the flat `facilities` list |
|
||||
| 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.12** | Use `INFORMATION_SCHEMA.STATISTICS` check |
|
||||
| 17 | **`batch_alter_table` is SQLite-only** | Use direct `ALTER TABLE` for MySQL migrations |
|
||||
| 18 | **Set `REDIS_URL` in production** | `memory://` is per-process; Gunicorn needs Redis for accurate shared counters |
|
||||
| 19 | **"Project" → "Contract" is UI-only** | Backend identifiers unchanged |
|
||||
| 20 | **`display_name` not `username` in templates** | Respects full_name; username is login identity only |
|
||||
| 21 | **`mobile_local_id` idempotency on all mobile write endpoints** | Network retries must not create duplicate records |
|
||||
| 22 | **Photo upload before inspection/issue submission** | Server path must be known before the parent record is created |
|
||||
| 23 | **Migration deploy before new `app/__init__.py`** | New init imports models referencing new columns; columns must exist first |
|
||||
| 24 | **`import Combine` required in iOS files using `@Published`** | Swift 5.9+ does not auto-import Combine |
|
||||
| 25 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17 |
|
||||
| 26 | **SwiftData `#Predicate` — fetch all + filter in Swift for string comparisons** | Macro type-inference bug with string literals across model type boundaries |
|
||||
| 27 | **Sequential `await` for reference data fetches in SyncManager** | `async let` causes Swift 6 actor-isolation warnings on Decodable structs |
|
||||
| 28 | **`hmac.compare_digest()` for token comparison** | Prevents timing oracle attacks |
|
||||
| 29 | **`get_customer_scope()` uses bulk project query** | Replaces per-assignment loop |
|
||||
| 30 | **CSV exports always call `log_action(ACTION_EXPORT, ...)`** | Data exports are compliance-relevant audit events |
|
||||
|
||||
---
|
||||
|
||||
## 20. Change Philosophy
|
||||
## 21. Change Philosophy
|
||||
|
||||
1. **Surgical, additive patches** — smallest possible change to achieve the goal
|
||||
2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise
|
||||
|
||||
Reference in New Issue
Block a user