04/25 updated Claude.md, Readme.md, and .gitignore
This commit is contained in:
@@ -34,4 +34,3 @@ Thumbs.db
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
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/<id>/edit`, `/users/<id>/delete`, `/users/<id>/toggle-active`, `/notification-matrix` |
|
||||||
|
| `dashboard` | `/` | `GET /` |
|
||||||
|
| `facilities` | `/facilities` | CRUD + `/facilities/<id>/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 <access_token>`
|
||||||
|
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=<DIGEST_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/<id>`
|
||||||
|
- 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=<DIGEST_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 `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">`
|
||||||
|
- **Never nest `<form>` 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 `<form>` 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
|
||||||
@@ -1 +1,293 @@
|
|||||||
# janitorial_qc
|
# 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 <repo-url> 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=<long-random-string>
|
||||||
|
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=<random-secret-for-cron-auth>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **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.
|
||||||
Reference in New Issue
Block a user