# 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 12 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. [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) --- ## 1. Project Overview **JQC (Janitorial Quality Control)** is a production-grade, full-stack web application that manages: - Janitorial service contracts organised as **Contracts (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) | | Rate limiting | Flask-Limiter (in-memory storage; swap for Redis in multi-worker) | | 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 — limiter, csrf, db, mail, login_manager │ ├── 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/*; rate-limited login/refresh │ │ ├── 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/* │ ├── 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 │ ├── 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 │ └── phase12_performance_indexes.py ← HEAD ├── config.py ├── gunicorn_config.py ├── requirements.txt # Includes Flask-Limiter ├── run.py └── wsgi.py ``` --- ## 4. Environment & Configuration ### Required Environment Variables | Variable | Notes | |---|---| | `SECRET_KEY` | Flask secret — no fallback; startup fails if absent | | `DATABASE_URL` | 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 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` | ### Email SSL Auto-Detection ```python MAIL_USE_SSL = _mail_port == 465 MAIL_USE_TLS = not MAIL_USE_SSL ``` **Critical:** Never set both to `True` — Flask-Mail breaks silently. ### File Uploads - `UPLOAD_FOLDER` = `app/static/uploads/` - `MAX_CONTENT_LENGTH` = 50 MB - Allowed: `png`, `jpg`, `jpeg`, `gif` --- ## 5. Database Models ### User ``` users: id, username (unique, indexed), full_name, email (unique, indexed), password_hash, role (ENUM), created_at, active, password_set, set_password_token (indexed), set_password_token_expires ``` **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. ### Facility / Area ``` facilities: id, name, address, contact_person, contact_phone, active, project_id (FK) areas: id, facility_id (FK), name, area_type ``` **`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other` ### Project / CustomerAssignment ``` projects: id, name, description, project_manager_id, active, created_at 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 ``` **Indexed columns (Phase 12):** `status`, `facility_id`, `inspector_id`, `inspection_date` **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 ``` **Indexed columns (Phase 12):** `status`, `severity`, `assigned_to`, `reported_at` **Issue status flow:** `open` → `in_progress` → `pending_verification` → `resolved` **Quick-assign:** `POST /issues//quick-assign` — AJAX endpoint for admin/director. Returns JSON. Sends assignment notification. Logs to audit trail. ### Notification / NotificationPreference ``` notifications: id, user_id, title, body, link, is_read, created_at, issue_id, event_type 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 ``` notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON) UniqueConstraint(event_type, role_key) ``` ### AuditLog ``` audit_logs: id, user_id (nullable, SET NULL on delete), 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) ``` **Token cleanup:** 1. **Passive** — on every successful API login, expired/revoked tokens for that user are deleted 2. **Cron** — `POST /notifications/cleanup-tokens?token=` deletes all globally --- ## 6. Role & Permission Matrix | Area | admin | director | project_manager | inspector | customer | |---|---|---|---|---|---| | Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped | | Users | ✅ | ✅ | ❌ | ❌ | ❌ | | Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ | | Customers | ✅ | ✅ | ❌ | ❌ | ❌ | | Facilities | ✅ | ✅ | ✅ | read | scoped | | Contracts | ✅ | ✅ | ✅ | read | scoped | | Templates | ✅ | ✅ | ❌ | ❌ | ❌ | | Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read | | Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | read | | Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ | | Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ | | Reports | ✅ | ✅ | ✅ | ✅ | scoped | | Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ | | Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ | | Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ | ### Decorator Map ```python @admin_required # role == 'admin' only @supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes @project_manager_required # role in ('admin', 'director', 'project_manager') @customer_required # role == 'customer' only ``` --- ## 7. Blueprint Prefixes & Route Inventory | Blueprint | Prefix | Notable routes | |---|---|---| | `auth` | `/auth` | `/login` (rate-limited 20/min), `/logout`, `/profile`, `/users/*`, `/notification-matrix` | | `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) | | `facilities` | `/facilities` | CRUD + area management | | `projects` | `/projects` | CRUD + customer assignment management | | `customers` | `/customers` | list (expired invitation banner), invite, set-password, manage, import CSV | | `inspections` | `/inspections` | list (stale badge, `now` passed from route), 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 //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) | | `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` | --- ## 8. Utility Modules ### `time_utils.py` `now_eastern()` — always use this, never `datetime.utcnow()`. ### `audit.py` `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. ### `forms.py` All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role. ### `notifications.py` `notify()`, `notify_by_matrix()`, `notify_customers_for_facility()` — all email sent in background thread. ### `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) ### Auth Flow 1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex) 2. Bearer token on every request 3. `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued) 4. `POST /api/v1/auth/logout` → revokes refresh token ### Rate Limits | Endpoint | Limit | |---|---| | `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` ### CSRF Exemption Pattern ```python csrf.exempt(api_bp) # BEFORE register_api(app) — order matters register_api(app) ``` ### Phase 2 (planned) `api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos` — all stubbed in `app/api/__init__.py`. --- ## 10. Notification System ### Event Constants (`app/models/notification.py`) ``` inspection_completed, issue_assigned, issue_reassigned, issue_unassigned, issue_status, issue_comment, issue_follow_update, issue_flagged, issue_created, issue_updated_customer, verification_requested, sla_alert, customer_inspection_completed ``` ### Cron Endpoints (all require `token=DIGEST_SECRET`) | Endpoint | Purpose | Schedule | |---|---|---| | `POST /notifications/send-digest` | Digest email delivery | `0 7 * * *` | | `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 | Severity | Window | At-Risk | |---|---|---| | critical | 4h | 3h | | high | 24h | 18h | | medium | 72h | 54h | | low | 168h | 126h | SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron notifications. --- ## 12. 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` --- ## 13. PDF Export ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF. --- ## 14. Scheduled Reports Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`. Cron: `POST /scheduled-reports/run?secret=` --- ## 15. Rate Limiting Initialised in `app/__init__.py` as a module-level extension: ```python limiter = Limiter( key_func = get_remote_address, default_limits = [], storage_uri = 'memory://', # ← swap for 'redis://...' in multi-worker ) limiter.init_app(app) ``` Import in routes: `from app import limiter`, then `@limiter.limit('N per period')`. > **Multi-worker caveat:** `memory://` is per-process. With multiple Gunicorn workers the effective limit is `N × workers`. Use Redis for shared-state enforcement. --- ## 16. 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 ``` ### MySQL ENUM Change Protocol (3 steps — always follow) ```sql -- 1. Expand to include both values ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL; -- 2. Migrate data UPDATE users SET role = 'director' WHERE role = 'supervisor'; -- 3. Contract to remove old value 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. ### 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) ``` --- ## 17. Frontend Conventions ### Active Nav Tab ```css /* base.html