Files
LT_Janitorial_Quality_Control/CLAUDE.md
T
2026-04-27 15:26:24 -04:00

28 KiB
Raw Blame History

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 + post-review hardening + UX improvements)


Table of Contents

  1. Project Overview
  2. Tech Stack
  3. Repository Layout
  4. Environment & Configuration
  5. Database Models
  6. Role & Permission Matrix
  7. Blueprint Prefixes & Route Inventory
  8. Utility Modules
  9. Mobile API (Phase 7)
  10. Notification System
  11. SLA Engine
  12. Audit Trail
  13. PDF Export
  14. Scheduled Reports
  15. Rate Limiting
  16. Alembic Migration Chain
  17. Frontend Conventions
  18. Infrastructure
  19. Known Constraints & Hard Rules
  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 (Redis-backed in production via REDIS_URL; falls back to in-process memory for dev)
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
REDIS_URL Optional. e.g. redis://127.0.0.1:6379/0. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. Falls back to in-process memory if absent.

Email SSL Auto-Detection

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_namefull_name.strip() or falls back to username. Use this everywhere in templates — never use .username for display.

Expired invitations: Customer accounts with password_set=False and set_password_token_expires < now_eastern() are flagged on the /customers index with an inline Resend button.

Customer self-service setup: When a customer clicks their invitation link, set_password.html prompts them to choose their own username (replacing the auto-generated placeholder) and set a password. The set_password route saves form.username.data to user.username before committing. The SetPasswordForm validates username uniqueness inline via validate_username().

Token verification: User.verify_set_password_token() guards in order: token present → DB lookup → password_set=False check → expiry check → hmac.compare_digest() constant-time comparison. The password_set=False guard ensures accounts already activated cannot be re-used via a stale token. compare_digest is wrapped in try/except to safely handle type mismatches.

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: openin_progresspending_verificationresolved

Quick-assign: POST /issues/<id>/quick-assign — AJAX endpoint for admin/director. Returns JSON. Sends assignment notification. Logs to audit trail.

Assignable staff roles: admin, director, inspector — all three appear in the assignment dropdown on the issue view, create, and list quick-assign forms. admin was previously absent from the view/create dropdowns; corrected.

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. CronPOST /notifications/cleanup-tokens?token=<DIGEST_SECRET> 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

@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; list grouped by Contract with collapsible sections; detail page has Back button
projects /projects CRUD + customer assignment management
customers /customers list (expired invitation banner), invite (name + email only), set-password (customer chooses username + password), manage, import CSV
inspections /inspections list (stale badge, now passed from route), start (template + facility only — area and notes removed), execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect
templates /templates list, create, edit, delete, form editor, preview
issues /issues list (SLA column, facility filter, quick-assign dropdown), view, create, update, verify, comment, follow/unfollow, verification queue, delete
issues /issues POST /<id>/quick-assign — JSON AJAX, admin/director only
notifications /notifications list, mark-read, mark-all-read, preferences (pause-all toggle), send-digest (cron), check-sla (cron), cleanup-tokens (cron)
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. Uses a single bulk Facility.project_id.in_(...) query for project-scoped assignments — never N+1.

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

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.

SLA filter on issues list: When ?sla= is active, the full matching result set is loaded and filtered in Python, then wrapped in _SLAFilteredPage (defined at the top of routes/issues.py). This satisfies the template's pagination interface (.items, .page, .pages, .iter_pages()) without any template changes. Pagination nav is automatically suppressed via the existing {% if issues.pages > 1 %} guard.


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

ACTION_EXPORT coverage: Both /reports/export/inspections and /reports/export/issues call log_action(ACTION_EXPORT, ...) before streaming the CSV response.

Logging coverage: All route modules now import logging and define a module-level logger = logging.getLogger(__name__). Previously missing from facilities.py, templates.py, reports.py, and dashboard.py.


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=<DIGEST_SECRET>


15. Rate Limiting

Initialised in app/__init__.py as a module-level extension:

limiter = Limiter(
    key_func       = get_remote_address,
    default_limits = [],
    storage_uri    = os.environ.get('REDIS_URL', 'memory://'),
)
limiter.init_app(app)

Import in routes: from app import limiter, then @limiter.limit('N per period').

Production: Set REDIS_URL=redis://127.0.0.1:6379/0 in the server environment. Counters are then shared across all Gunicorn workers and rate limits are correctly enforced.

Development: REDIS_URL absent → falls back to memory:// (in-process, single-worker). No Redis install required locally.

redis package is in requirements.txt. Install it with pip install -r requirements.txt before setting REDIS_URL.


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)

-- 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

# WRONG
Model.query.get(id)
Model.query.get_or_404(id)

# CORRECT
obj = db.session.get(Model, id)
if obj is None: abort(404)

All Model.query.get() calls have been replaced — including load_user() in models/user.py and three calls in utils/notifications.py.


17. Frontend Conventions

Active Nav Tab

/* base.html <style> block */
.navbar-dark .navbar-nav .nav-link.active {
    background-color: rgba(255,255,255,0.18);
    color: #fff !important;
    border-radius: 6px;
    font-weight: 600;
    box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}

Active state detected via request.endpoint.startswith('<blueprint>.') in each nav <a> tag.

Display Names

Always use user.display_name in templates — never .username for display purposes.

Status Label Map

DB value Displayed as
completed Submitted
in_progress In Progress
flagged Flagged
open Open
resolved Resolved
pending_verification Pending Verification

Forms

  • Flask-WTF CSRF auto-applied to all web forms
  • Manual forms: <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  • Never nest <form> tags — browsers silently discard inner forms

Real-Time

SSE banned. All "live" updates use polling.


18. Infrastructure

Gunicorn

bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
timeout = 30

Logs: /home/jqc/logs/gunicorn-error.log, /home/jqc/logs/gunicorn-access.log

Application Logging

  • RotatingFileHandlerlogs/jqc.log (5 × 5 MB)
  • StreamHandler → stdout (journalctl)
  • Format: [YYYY-MM-DD HH:MM:SS] LEVEL in module: message

Nginx

  • client_max_body_size 50M
  • Passes X-Forwarded-For
0 7    * * * curl -s -X POST https://your-domain.com/notifications/send-digest \
                   -d "token=SECRET&frequency=daily"
*/30 * * * * curl -s -X POST https://your-domain.com/notifications/check-sla \
                   -d "token=SECRET"
0 3    * * * curl -s -X POST https://your-domain.com/notifications/cleanup-tokens \
                   -d "token=SECRET"
0 8    * * * curl -s -X POST https://your-domain.com/scheduled-reports/run \
                   -d "secret=SECRET"

19. Known Constraints & Hard Rules

# Rule Rationale
1 No SSE Exhausted Gunicorn sync worker pool
2 now_eastern() always utcnow() caused incorrect SLA cutoffs
3 3-step MySQL ENUM changes Skipping causes data loss
4 Port 465 → SSL; 587 → STARTTLS Both True breaks Flask-Mail
5 CSRFProtect as extension instance Flask-WTF 1.0+ removed per-route @csrf_exempt
6 supervisor_required name preserved Renaming would touch 30+ route decorators
7 Score 0 = unanswered Excluded from calculation — not the same as scoring zero
8 12-column grid in PDF Must not collapse in print/PDF
9 No nested <form> tags Browsers silently discard inner forms
10 log_action() after db.session.commit() Entity ID must exist 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 loops cause N+1
14 Email in background thread Never block HTTP response
15 Open-redirect guards _safe_next() in auth.py; _safe_referrer() in customers.py
16 CREATE INDEX IF NOT EXISTS not on MySQL < 8.0.1 Use information_schema — see phase12
17 Set REDIS_URL in production memory:// is per-process; multi-worker Gunicorn needs Redis for accurate shared counters
18 "Project" → "Contract" is UI-only Backend identifiers unchanged
19 display_name not username in templates Respects full_name; username is login identity only
20 open_issues count derived from len() not .count() Avoids a second DB round-trip on the dashboard
21 supervisor removed from Python-side ENUM Phase 11 migration complete — both DB and model ENUM must stay in sync
22 SLA filter loads full result set (no paginate) SLA status is computed in Python; _SLAFilteredPage wrapper satisfies the template pagination interface
23 hmac.compare_digest() for token comparison Prevents timing oracle attacks on verify_set_password_token
24 get_customer_scope() uses bulk project query Single Facility.project_id.in_(project_ids) replaces per-assignment loop
25 CSV exports always call log_action(ACTION_EXPORT, ...) Data exports are compliance-relevant audit events
26 StartInspectionForm has no area_id or notes fields Both removed from the start page; Inspection constructor receives area_id=None, notes=None explicitly
27 Customer set-password page collects username + password SetPasswordForm includes username field; route saves it to user.username, replacing the auto-generated placeholder
28 verify_set_password_token requires password_set=False Prevents reuse of a stale token on an already-activated account
29 Facilities list grouped by Contract in route, not template list_facilities() builds a grouped OrderedDict before rendering; template iterates grouped, not the flat facilities list

20. Change Philosophy

  1. Surgical, additive patches — smallest possible change to achieve the goal
  2. Preserve all routes, function names, variable names unless explicitly directed otherwise
  3. Never remove existing functionality unless explicitly directed
  4. Log all create/update/delete actions via log_action()
  5. Migration existence checks — all migrations safe to re-run
  6. Full file contents for 13 file changes; deployment map for larger changesets
  7. Explicit deploy instructions — migration steps separated from code steps
  8. Root cause analysis on errors — never apply temporary workarounds