From 9d9880e20b0ab2d140df6ebd17ee8826a950413e Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 25 Apr 2026 10:25:49 -0400 Subject: [PATCH] 04/25 updated Claude.md, Readme.md, and .gitignore --- .gitignore | 3 +- CLAUDE.md | 773 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 294 +++++++++++++++++++- 3 files changed, 1067 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index b46b15c..85c6595 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,4 @@ app/static/uploads/ Thumbs.db # Logs -*.log -Claude.md \ No newline at end of file +*.log \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..93ef357 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,773 @@ +# Claude.md — JQC Developer Reference + +> **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) + +--- + +## Table of Contents + +1. [Project Overview](#1-project-overview) +2. [Tech Stack](#2-tech-stack) +3. [Repository Layout](#3-repository-layout) +4. [Environment & Configuration](#4-environment--configuration) +5. [Database Models](#5-database-models) +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. [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) + +--- + +## 1. Project Overview + +**JQC (Janitorial Quality Control)** is a production-grade, full-stack web application that manages: + +- Janitorial service contracts organised as **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 +- **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 + +The application is actively deployed in production and maintained by a single developer/administrator. + +--- + +## 2. Tech Stack + +| Layer | Technology | +|---|---| +| Language | Python 3.11+ | +| Web framework | Flask (application factory pattern) | +| ORM | Flask-SQLAlchemy (SQLAlchemy 2.x) | +| Database | MySQL (via PyMySQL driver) | +| Auth (web) | Flask-Login + Flask-WTF CSRF | +| Auth (API) | JWT access tokens + opaque refresh tokens (PyJWT) | +| Migrations | Flask-Migrate / Alembic | +| Email | Flask-Mail (SMTP, background threading) | +| PDF generation | ReportLab | +| Forms | WTForms + Flask-WTF | +| Templating | Jinja2 | +| 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) | +| Timezone | All datetimes stored as US/Eastern (naive, via `now_eastern()`) | + +--- + +## 3. Repository Layout + +``` +lt_janitorial_quality_control/ +├── app/ +│ ├── __init__.py # Application factory — create_app() +│ ├── 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/* +│ │ ├── 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 +│ │ ├── 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 +│ │ ├── project.py # Project, CustomerAssignment +│ │ ├── scheduled_report.py # ScheduledReport +│ │ └── user.py # User (with password-setup workflow) +│ ├── routes/ +│ │ ├── __init__.py +│ │ ├── audit.py # /audit/* +│ │ ├── auth.py # /auth/* (users, login, notification matrix) +│ │ ├── customers.py # /customers/* +│ │ ├── dashboard.py # / +│ │ ├── facilities.py # /facilities/* +│ │ ├── inspections.py # /inspections/* +│ │ ├── issues.py # /issues/* +│ │ ├── notifications.py # /notifications/* +│ │ ├── projects.py # /projects/* +│ │ ├── reports.py # /reports/* +│ │ ├── scheduled_reports.py # /scheduled-reports/* +│ │ └── templates.py # /templates/* +│ ├── 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 +│ │ └── ... +│ └── 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 +│ ├── 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 +│ ├── sla.py # sla_status(), sla_deadline(), sla_hours_remaining(), send_sla_alerts() +│ └── time_utils.py # now_eastern() +├── migrations/ +│ └── versions/ +│ ├── phase1_projects_roles.py +│ ├── phase6_features.py +│ ├── phase7_mobile_api.py +│ ├── 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 +``` + +--- + +## 4. Environment & Configuration + +### Required Environment Variables + +| 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` | +| `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_DEFAULT_SENDER` | From address | +| `DIGEST_SECRET` | Token used by cron to authenticate `/notifications/send-digest` | + +### 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. + +### 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 + +--- + +## 5. Database Models + +### 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 +``` + +**Role ENUM (current):** `admin`, `director`, `inspector`, `project_manager`, `customer` +> `supervisor` was renamed to `director` in Phase 11. The ENUM no longer contains `supervisor`. + +**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 + +### Facility / Area + +``` +facilities: id, name, address, contact_person, contact_phone, active, project_id (FK→projects) +areas: id, facility_id (FK), name, area_type +``` + +### Project / CustomerAssignment + +``` +projects: id, name, description, project_manager_id (FK→users), active, created_at +customer_assignments: id, user_id, project_id, facility_id (nullable) + └── UniqueConstraint(user_id, project_id, facility_id) +``` + +`facility_id = NULL` means the customer has access to all active facilities in the project. + +### InspectionTemplate / ChecklistItem / Inspection / InspectionResult + +``` +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 +``` + +**Scoring rule:** Items with `score = 0` mean "unanswered" and are excluded from score calculation entirely. + +**Follow-up workflow:** `parent_inspection_id` links re-inspections back to the original. + +### Issue / IssueComment / IssueFollower + +``` +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) +``` + +**Issue status flow:** `open` → `in_progress` → `pending_verification` → `resolved` + +### Notification / NotificationPreference + +``` +notifications: id, user_id, title, body, link, is_read, created_at, issue_id (nullable), event_type +notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency +``` + +### NotificationMatrix + +``` +notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON text) + └── UniqueConstraint(event_type, role_key) +``` + +### AuditLog + +``` +audit_logs: id, user_id (FK nullable on delete SET NULL), username (snapshot), user_role (snapshot), + action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address +``` + +### RefreshToken / DeviceToken + +``` +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) +``` + +### 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 +``` + +--- + +## 6. Role & Permission Matrix + +| Route Area | admin | director | project_manager | inspector | customer | +|---|---|---|---|---|---| +| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped | +| Users (`/auth/users`) | ✅ | ✅ | ❌ | ❌ | ❌ | +| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ | +| Customers (`/customers`) | ✅ | ✅ | ❌ | ❌ | ❌ | +| Facilities | ✅ | ✅ | ✅ | ✅ read | ✅ scoped | +| Projects | ✅ | ✅ | ✅ | ✅ read | ✅ scoped | +| Templates (create/edit) | ✅ | ✅ | ❌ | ❌ | ❌ | +| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | ✅ read | +| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ read | +| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ | +| Reports (export) | ✅ | ✅ | ✅ | ✅ | ✅ scoped | +| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ | +| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ | +| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ (TBD) | + +### 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 +``` + +> **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 | +|---|---|---| +| `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 | +| `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 | +| `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) +``` + +--- + +## 8. Utility Modules + +### `app/utils/time_utils.py` + +```python +from app.utils.time_utils import now_eastern +``` + +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. + +### `app/utils/audit.py` + +```python +from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT, ACTION_EXPORT + +log_action( + action = ACTION_CREATE, + entity_type = 'Facility', + entity_id = facility.id, + entity_label = facility.name, + details = f'address={facility.address}', +) +``` + +- 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. + +--- + +## 9. Mobile API (Phase 7) + +### Authentication Flow + +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 + +### 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 + +### 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) +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` + +--- + +## 10. Notification System + +### Event Types (constants in `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' +``` + +### Notification Matrix + +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()`) + +### 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. + +--- + +## 11. SLA Engine + +| Severity | Window | At-Risk Trigger | +|---|---|---| +| critical | 4 hours | 3 hours | +| high | 24 hours | 18 hours | +| medium | 72 hours | 54 hours | +| low | 168 hours (7 days) | 126 hours | + +- 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'` + +--- + +## 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'`) + +--- + +## 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`). + +--- + +## 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=` + +--- + +## 15. 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 ← HEAD +``` + +### MySQL ENUM Change Protocol (3 Steps) + +**Always** follow this sequence when modifying an ENUM column — skipping steps causes data loss or migration failures: + +```sql +-- Step 1: Expand ENUM to include BOTH old and new values +ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL; + +-- Step 2: Migrate existing data +UPDATE users SET role = 'director' WHERE role = 'supervisor'; + +-- Step 3: Contract ENUM 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. + +### Deprecated SQLAlchemy Patterns + +```python +# WRONG — deprecated in SQLAlchemy 2.x +User.query.get(user_id) + +# CORRECT +db.session.get(User, user_id) +``` + +Also: `filter()` must precede `limit()`. Avoid N+1 query patterns — use eager loading or bulk queries. + +--- + +## 16. Frontend Conventions + +### Base Template (`base.html`) + +- Bootstrap 5 (CDN) +- Chart.js (CDN, for dashboard charts) +- Navbar includes unread notification badge (injected via `@app.context_processor`) +- `pending_verification_count` badge for admin/director roles +- Jinja2 globals available in all templates: `csrf_token`, `enumerate`, `sla_status`, `sla_deadline`, `sla_hours_remaining`, `SLA_HOURS` + +### Forms + +- All web forms use Flask-WTF CSRF protection automatically +- Manual forms (without a WTForms object) must include `` +- **Never nest `
` tags** — browsers silently discard inner forms. Toggle/action forms must be siblings, not children, of edit forms. + +### File Uploads in Templates + +Photos are served from `app/static/uploads/` via `url_for('static', filename=path)`. + +### Real-Time Updates + +**SSE is banned.** Server-Sent Events exhausted the Gunicorn sync worker pool in production. All "live" updates use polling (JS `setInterval` + `fetch`). + +--- + +## 17. Infrastructure + +### Gunicorn (`gunicorn_config.py`) + +```python +bind = "127.0.0.1:8000" +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "sync" # SSE would require 'gevent' or 'eventlet' — banned +timeout = 30 +``` + +Log paths: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log` + +### Application Logging + +Configured in `create_app()`: +- `RotatingFileHandler` → `logs/jqc.log` (5 × 5 MB) +- `StreamHandler` → stdout (captured by journalctl) +- Format: `[YYYY-MM-DD HH:MM:SS] LEVEL in module: message` +- Applied to both `app.logger` and the root logger + +### Nginx + +- Reverse proxies to `127.0.0.1:8000` +- Must set `client_max_body_size 50M` to match `MAX_CONTENT_LENGTH` +- Passes `X-Forwarded-For` for IP capture in audit logs + +### Deployment Sequence (typical change) + +```bash +git pull +pip install -r requirements.txt # if dependencies changed +flask db upgrade # if migrations added +sudo systemctl restart jqc # or equivalent gunicorn restart +``` + +--- + +## 18. Known Constraints & Hard Rules + +| # | Rule | Rationale | +|---|---|---| +| 1 | **No SSE** | Exhausted Gunicorn sync worker pool in production | +| 2 | **`now_eastern()` always** | `datetime.utcnow()` caused incorrect SLA purge cutoffs | +| 3 | **3-step MySQL ENUM changes** | Skipping steps causes data loss | +| 4 | **Port 465 → SSL, port 587 → STARTTLS** | Both flags True breaks Flask-Mail | +| 5 | **`CSRFProtect` as extension instance, not per-route** | Flask-WTF 1.0+ removed `@csrf_exempt` | +| 6 | **`supervisor_required` decorator name preserved** | Changing it would touch 30+ route decorators | +| 7 | **Score 0 = unanswered, exclude from calculation** | Not the same as a score of zero | +| 8 | **12-column grid in PDF** | Must not collapse in print/PDF views | +| 9 | **No nested `` tags** | Browsers silently discard inner forms | +| 10 | **`log_action()` after `db.session.commit()`** | Entity ID must be committed before audit capture | +| 11 | **`db.session.get(Model, id)` not `Model.query.get(id)`** | SQLAlchemy 2.x deprecation | +| 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement | +| 13 | **Bulk queries in customer list** | Per-customer query loop caused N+1 in `/customers` | +| 14 | **Email sent in background thread** | Never block HTTP response | +| 15 | **Open-redirect guard on login** | `_safe_next()` validates relative URLs only | + +--- + +## 19. Change Philosophy + +1. **Surgical, additive patches** — smallest possible change to achieve the goal +2. **Preserve all routes, function names, and variable names** unless explicitly asked to rename +3. **Never remove existing functionality** unless explicitly directed +4. **Log all create/update/delete actions** via `log_action()` +5. **Test migrations with existence checks** so they are safe to re-run +6. **Full file contents for 1–3 file changes**; zip package with placement map for larger changesets +7. **Explicit deploy instructions** always included (migration steps separated from code changes) +8. **Root cause analysis** on errors — never apply temporary workarounds \ No newline at end of file diff --git a/README.md b/README.md index f976277..e0176a7 100644 --- a/README.md +++ b/README.md @@ -1 +1,293 @@ -# janitorial_qc \ No newline at end of file +# JQC — Janitorial Quality Control System + +A production-grade web application for managing janitorial service contracts, facility inspections, issue tracking, and client reporting. + +--- + +## Features + +- **Inspection Management** — Execute structured inspections against configurable templates with a drag-and-drop form builder supporting ratings, pass/fail, photos, signatures, and free-form fields +- **Issue Tracking** — Full lifecycle management (open → in-progress → pending verification → resolved) with SLA enforcement, follower subscriptions, and resolution photo uploads +- **Customer Portal** — Scoped facility visibility for client accounts with invitation-based onboarding (email link, 72-hour token) +- **Notification System** — In-app + email notifications driven by an admin-controlled routing matrix; per-user preferences and digest mode +- **Reports** — On-demand PDF/CSV scorecards and scheduled recurring email reports (daily/weekly/monthly) +- **Audit Trail** — Immutable log of every create, update, and delete action with actor and IP capture +- **Mobile API** — JWT-authenticated REST API for the companion React Native / Expo mobile application +- **Project Hierarchy** — Facilities grouped into Projects with optional Project Manager assignment and per-project customer access control + +--- + +## Tech Stack + +| Layer | Technology | +|---|---| +| Backend | Python / Flask | +| Database | MySQL | +| ORM / Migrations | SQLAlchemy + Alembic | +| Auth (web) | Flask-Login + Flask-WTF (CSRF) | +| Auth (API) | JWT + opaque refresh tokens | +| Email | Flask-Mail (SMTP) | +| PDF | ReportLab | +| Frontend | Bootstrap 5, Chart.js, Jinja2 | +| Server | Gunicorn + Nginx | +| Mobile | React Native + Expo | + +--- + +## Prerequisites + +- Python 3.11+ +- MySQL 8.x +- A configured SMTP server (port 465 or 587) + +--- + +## Installation + +### 1. Clone and create a virtual environment + +```bash +git clone jqc +cd jqc +python -m venv venv +source venv/bin/activate # Linux / macOS +# venv\Scripts\activate # Windows +``` + +### 2. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 3. Create the database + +```sql +CREATE DATABASE jqc CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'jqc'@'localhost' IDENTIFIED BY 'your_password'; +GRANT ALL PRIVILEGES ON jqc.* TO 'jqc'@'localhost'; +FLUSH PRIVILEGES; +``` + +### 4. Configure environment variables + +Create a `.env` file in the project root (never commit this file): + +```dotenv +SECRET_KEY= +DATABASE_URL=mysql+pymysql://jqc:your_password@localhost/jqc + +# Email +MAIL_SERVER=smtp.example.com +MAIL_PORT=587 +MAIL_USERNAME=noreply@example.com +MAIL_PASSWORD=smtp_password +MAIL_DEFAULT_SENDER=noreply@example.com + +# Application +APP_BASE_URL=https://your-domain.com +DIGEST_SECRET= +``` + +> **Email SSL:** Port 465 uses implicit SSL (`MAIL_USE_SSL=True`). Port 587 uses STARTTLS (`MAIL_USE_TLS=True`). The application auto-detects based on `MAIL_PORT` — never set both flags to True. + +### 5. Run database migrations + +```bash +flask db upgrade +``` + +### 6. Start the development server + +```bash +python run.py +``` + +The application will be available at `http://localhost:5000`. + +--- + +## Production Deployment + +### Gunicorn + +```bash +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/`. + +### Nginx (recommended configuration) + +```nginx +server { + listen 443 ssl; + server_name your-domain.com; + + client_max_body_size 50M; # must match MAX_CONTENT_LENGTH in config.py + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /static/ { + alias /path/to/jqc/app/static/; + expires 30d; + } +} +``` + +### Systemd service (example) + +```ini +[Unit] +Description=JQC Gunicorn +After=network.target mysql.service + +[Service] +User=jqc +WorkingDirectory=/home/jqc/lt_janitorial_quality_control +EnvironmentFile=/home/jqc/.env +ExecStart=/home/jqc/venv/bin/gunicorn -c gunicorn_config.py wsgi:app +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Cron Jobs + +Two background tasks require scheduled execution: + +### SLA Alerts + +Checks all open issues against their SLA window and dispatches notifications: + +```bash +# Run every 30 minutes +*/30 * * * * /home/jqc/venv/bin/python -c " +from app import create_app +from app.utils.sla import send_sla_alerts +app = create_app('production') +with app.app_context(): + send_sla_alerts() +" +``` + +### Digest Emails and Scheduled Reports + +```bash +# Daily digest — run at 7:00 AM +0 7 * * * curl -s -X POST "https://your-domain.com/notifications/send-digest?frequency=daily&secret=YOUR_DIGEST_SECRET" + +# Scheduled reports +0 8 * * * curl -s -X POST "https://your-domain.com/scheduled-reports/run?secret=YOUR_DIGEST_SECRET" +``` + +--- + +## User Roles + +| Role | Description | +|---|---| +| **admin** | Full access to all features including Audit Trail and Notification Matrix | +| **director** | Broad access equivalent to admin, excluding Audit Trail and Notification Matrix | +| **project_manager** | Manages projects, facilities, and reports; cannot manage users or system settings | +| **inspector** | Executes inspections and manages assigned issues | +| **customer** | Read-only portal scoped to assigned facilities; receives notifications on their facilities | + +### Creating the First Admin Account + +```bash +flask shell +>>> from app import db +>>> from app.models.user import User +>>> u = User(username='admin', email='admin@example.com', role='admin') +>>> u.set_password('your-secure-password') +>>> db.session.add(u) +>>> db.session.commit() +``` + +--- + +## Customer Onboarding + +1. Admin navigates to **Customers → Invite Customer** +2. Enter the customer's name and email address +3. The system auto-generates a username and sends an invitation email with a 72-hour setup link +4. Customer clicks the link, sets their password, and gains access to their scoped portal +5. Admin assigns the customer to one or more Projects/Facilities via **Customers → Manage Assignments** + +--- + +## Mobile API + +The REST API is available at `/api/v1/` and uses JWT Bearer token authentication. + +### Authentication Endpoints + +| Method | Endpoint | Description | +|---|---|---| +| POST | `/api/v1/auth/login` | Login; returns access + refresh tokens | +| POST | `/api/v1/auth/refresh` | Rotate refresh token; returns new access token | +| POST | `/api/v1/auth/logout` | Revoke refresh token | +| GET | `/api/v1/auth/me` | Return current user profile | +| POST | `/api/v1/devices/register` | Register APNs device token for push notifications | + +Access tokens expire after 60 minutes. Refresh tokens are valid for 30 days and rotate on every use. + +--- + +## Database Migrations + +```bash +# Apply all pending migrations +flask db upgrade + +# Create a new migration after model changes +flask db migrate -m "description of change" + +# Roll back one migration +flask db downgrade +``` + +> **MySQL ENUM changes require three steps:** expand the ENUM to include both values, migrate existing data, then contract the ENUM. See `Claude.md` for the full protocol. + +--- + +## Application Logs + +| Log File | Contents | +|---|---| +| `logs/jqc.log` | Application log (rotating, 5 × 5 MB) | +| `/home/jqc/logs/gunicorn-error.log` | Gunicorn worker errors | +| `/home/jqc/logs/gunicorn-access.log` | HTTP access log | + +--- + +## Configuration Reference + +| Variable | Default | Description | +|---|---|---| +| `SECRET_KEY` | — *required* | Flask session signing key | +| `DATABASE_URL` | — *required* | SQLAlchemy connection URI | +| `MAIL_SERVER` | — | SMTP hostname | +| `MAIL_PORT` | `587` | SMTP port (465 = SSL, 587 = STARTTLS) | +| `MAIL_USERNAME` | — | SMTP username | +| `MAIL_PASSWORD` | — | SMTP password | +| `MAIL_DEFAULT_SENDER` | `noreply@janitorialqc.local` | From address | +| `APP_BASE_URL` | `""` | Base URL for links in emails | +| `DIGEST_SECRET` | — | Token for authenticating cron requests | +| `MAX_CONTENT_LENGTH` | `50MB` | Maximum upload size per request | + +--- + +## License + +See `LICENSE` for terms. \ No newline at end of file