388 lines
15 KiB
Markdown
388 lines
15 KiB
Markdown
# JQC — Janitorial Quality Control System
|
||
|
||
A production-grade, multi-tenant web application for managing janitorial service contracts, facility inspections, issue tracking, and client reporting. Built as a shared-codebase SaaS with database-per-tenant isolation.
|
||
|
||
---
|
||
|
||
## 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. Starting an inspection requires only a Template and Facility selection.
|
||
- **Issue Tracking** — Full lifecycle management (open → in-progress → pending verification → resolved) with SLA enforcement, follower subscriptions, resolution photo uploads, and inline quick-assign from the issues list
|
||
- **Customer Portal** — Scoped facility visibility for client accounts with invitation-based onboarding; admin enters name and email only, customer chooses their own username and password via a 72-hour emailed link; expired invitation warnings surface on the admin dashboard
|
||
- **Notification System** — In-app + email notifications driven by an admin-controlled routing matrix; per-user preferences including digest mode and a one-click "Pause All Emails" toggle
|
||
- **Reports & Analytics** — Comprehensive reporting suite accessible from the second nav position:
|
||
- *Overview & Trends* — facility scorecards, score trend charts, and PDF/CSV exports
|
||
- *Issues Aging* — open issues grouped into age buckets (`<24h` → `>4 weeks`) with SLA status per issue; Excel export
|
||
- *SLA Compliance* — resolved-issue SLA compliance % broken down by severity tier and facility, with progress bars; Excel export (2 sheets)
|
||
- *Follow-up Closure Rate* — tracks which flagged inspections received a re-inspection; per-facility closure rates; Excel export
|
||
- *Customer Facility PDF Summary* — downloadable PDF report for a facility covering KPIs, area scores, and open issues; accessible from the scorecard page
|
||
- *Inspector Performance* — per-inspector KPI summary with Excel export (admin/director only)
|
||
- *Scheduled Reports* — recurring email delivery of summary, facility, or issues reports (daily/weekly/monthly)
|
||
- **Audit Trail** — Immutable log of every create, update, and delete action with actor and IP capture
|
||
- **Support Chat** — Groq AI-powered chatbot for customers with preset FAQ quick-replies; automatic escalation to a ticketing system when the AI cannot resolve the issue; admins manage and reply to tickets at `/support/admin/tickets` with in-app and email notifications on every state change
|
||
- **Inspector Performance Export** — Excel (`.xlsx`) export of the inspector performance summary with a color-coded KPI sheet and a detailed inspection log sheet
|
||
- **Mobile API** — JWT-authenticated REST API (Phase 7) for the companion iPad native app; rate-limited login and refresh endpoints
|
||
- **Contract Hierarchy** — Facilities grouped into Contracts (internally "Projects") with optional Contract Manager assignment and per-contract 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 |
|
||
| Rate limiting | Flask-Limiter (Redis-backed in production; in-process fallback for dev) |
|
||
| Email | Flask-Mail (SMTP) |
|
||
| PDF | ReportLab |
|
||
| Frontend | Bootstrap 5, Chart.js, Jinja2 |
|
||
| Excel export | openpyxl |
|
||
| AI chatbot | Groq API (`llama-3.3-70b-versatile`) |
|
||
| Server | Gunicorn + Nginx |
|
||
| Mobile | SwiftUI + SwiftData (iOS 17+) |
|
||
|
||
---
|
||
|
||
## Prerequisites
|
||
|
||
- Python 3.11+
|
||
- MySQL 5.7+ (or 8.0+)
|
||
- 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>
|
||
|
||
# Rate limiting (optional — recommended for production multi-worker deployments)
|
||
# REDIS_URL=redis://127.0.0.1:6379/0
|
||
```
|
||
|
||
> **Email SSL:** Port 465 uses implicit SSL; port 587 uses STARTTLS. 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/`.
|
||
|
||
> **Rate limiting:** Flask-Limiter uses Redis when `REDIS_URL` is set in the environment, falling back to in-process memory for local development. In production with multiple Gunicorn workers, set `REDIS_URL=redis://127.0.0.1:6379/0` so rate-limit counters are shared across all workers. The `redis` package is included in `requirements.txt`.
|
||
|
||
### 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
|
||
EnvironmentFile=/etc/jqc/control.env # control-plane + multi-tenant vars
|
||
ExecStart=/home/jqc/venv/bin/gunicorn -c gunicorn_config.py wsgi:app
|
||
Restart=on-failure
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
> `/etc/jqc/control.env` must be `chmod 640 / chown root:jqc`. It holds `CONTROL_DATABASE_URL`, `CONTROL_FERNET_KEY`, `PROVISION_DB_URL`, `TENANT_BASE_DOMAIN`, and `MULTI_TENANT_ENABLED`. The same file must be sourced in any shell session that runs provisioning commands (`set -a; . /etc/jqc/control.env; set +a`).
|
||
|
||
---
|
||
|
||
## Cron Jobs
|
||
|
||
Four background tasks require scheduled execution. All endpoints that require a token use `DIGEST_SECRET`.
|
||
|
||
```bash
|
||
# SLA alerts — every 30 minutes
|
||
*/30 * * * * curl -s -X POST "https://your-domain.com/notifications/check-sla" \
|
||
-d "token=YOUR_DIGEST_SECRET"
|
||
|
||
# Daily digest emails — 7:00 AM
|
||
0 7 * * * curl -s -X POST "https://your-domain.com/notifications/send-digest" \
|
||
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
|
||
|
||
# Expired/revoked API token cleanup — 3:00 AM
|
||
0 3 * * * curl -s -X POST "https://your-domain.com/notifications/cleanup-tokens" \
|
||
-d "token=YOUR_DIGEST_SECRET"
|
||
|
||
# Scheduled report delivery — 8:00 AM
|
||
0 8 * * * curl -s -X POST "https://your-domain.com/scheduled-reports/run" \
|
||
-d "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 contracts, facilities, and reports; cannot manage users or system settings |
|
||
| **inspector** | Executes inspections and manages assigned issues |
|
||
| **customer** | Portal scoped to assigned facilities; can create issues, comment on followed/reported issues, use the AI support chat, and submit/reply to support tickets |
|
||
|
||
### 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 → New Customer**
|
||
2. Enter the customer's **Full Name** and **Email** only — no username or password required from the admin
|
||
3. The system auto-generates a temporary username and sends an invitation email with a secure **72-hour setup link**
|
||
4. Customer clicks the link and arrives at the account setup page where they **choose their own username and set their password**
|
||
5. The account activates immediately and the customer can log in
|
||
6. Admin assigns the customer to Contracts/Facilities via **Customers → Manage**
|
||
|
||
> Expired invitations (token past 72 hours, password never set) are surfaced as a warning banner on the Customers page with inline **Resend** buttons. Resending generates a fresh 72-hour token.
|
||
|
||
---
|
||
|
||
## Mobile API
|
||
|
||
The REST API is available at `/api/v1/` and uses JWT Bearer token authentication.
|
||
|
||
### Authentication Endpoints
|
||
|
||
| Method | Endpoint | Rate Limit | Description |
|
||
|---|---|---|---|
|
||
| POST | `/api/v1/auth/login` | 10/min | Login; returns access + refresh tokens |
|
||
| POST | `/api/v1/auth/refresh` | 30/min | 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 |
|
||
|
||
Access tokens expire after 60 minutes. Refresh tokens are valid for 30 days and rotate on every use. Expired and revoked tokens are cleaned up automatically on each login and via a nightly cron job.
|
||
|
||
---
|
||
|
||
## Database Migrations
|
||
|
||
```bash
|
||
# Apply all pending migrations (existing / single-tenant DB)
|
||
flask db upgrade
|
||
|
||
# Create a new migration after model changes
|
||
flask db migrate -m "description of change"
|
||
|
||
# Roll back one migration
|
||
flask db downgrade
|
||
```
|
||
|
||
### Multi-tenant migrations
|
||
|
||
```bash
|
||
# Bootstrap a brand-new tenant DB (baseline schema + stamp head)
|
||
python -m control.tenant_migrate bootstrap --tenant <slug>
|
||
|
||
# Incremental upgrade for all active tenants (phase33+ onwards)
|
||
python -m control.tenant_migrate upgrade --tenant all
|
||
|
||
# Check each tenant's current revision vs chain head
|
||
python -m control.tenant_migrate current --tenant all
|
||
```
|
||
|
||
> **Never run `flask db upgrade` on an empty tenant database.** Fourteen historical phase migrations lack `INFORMATION_SCHEMA` guards and will fail against a DB that already has the baseline schema. Use `bootstrap` instead.
|
||
|
||
### MySQL Compatibility Notes
|
||
|
||
- **ENUM changes** require three steps: expand → migrate data → contract. Never skip steps.
|
||
- **`CREATE INDEX IF NOT EXISTS`** is not supported on MySQL < 8.0.1. Use `information_schema.statistics` existence checks instead — see `phase12_performance_indexes.py` for the reusable `_index_exists()` helper pattern.
|
||
|
||
---
|
||
|
||
## 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` | — | Authenticates all cron endpoints |
|
||
| `MAX_CONTENT_LENGTH` | `50MB` | Maximum upload size per request |
|
||
| `REDIS_URL` | — | Redis connection URI for shared rate-limit storage; optional but recommended in production |
|
||
| `GROQ_API_KEY` | — | Groq API key. When absent the AI chatbot is disabled; customers can still submit support tickets. |
|
||
| `GROQ_MODEL` | `llama-3.3-70b-versatile` | Groq model ID override |
|
||
| `MULTI_TENANT_ENABLED` | `false` | Set `true` to activate Host→tenant routing. Requires the four control-plane vars below. |
|
||
| `CONTROL_DATABASE_URL` | — | Control-plane DB URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control` |
|
||
| `CONTROL_FERNET_KEY` | — | Fernet key for encrypting tenant DB passwords. Generate once and store permanently. |
|
||
| `PROVISION_DB_URL` | — | MySQL account with `CREATE DATABASE`/`CREATE USER`/`GRANT`. |
|
||
| `TENANT_BASE_DOMAIN` | — | Subdomain apex, e.g. `jqc.app`. |
|
||
|
||
---
|
||
|
||
## Multi-Tenant Setup
|
||
|
||
JQC runs as a shared-codebase SaaS with database-per-tenant isolation. Each tenant gets its own MySQL database and least-privilege MySQL user. The control plane (`control/`) manages the tenant registry separately from any tenant's data.
|
||
|
||
### Prerequisites
|
||
|
||
```sql
|
||
-- Control database
|
||
CREATE DATABASE jqc_control CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||
CREATE USER 'jqc_control'@'localhost' IDENTIFIED BY '<pw>';
|
||
GRANT ALL PRIVILEGES ON jqc_control.* TO 'jqc_control'@'localhost'; FLUSH PRIVILEGES;
|
||
|
||
-- Provisioner account (creates per-tenant DBs + users)
|
||
CREATE USER 'jqc_provisioner'@'localhost' IDENTIFIED BY '<pw>';
|
||
GRANT ALL PRIVILEGES ON *.* TO 'jqc_provisioner'@'localhost' WITH GRANT OPTION;
|
||
GRANT CREATE USER ON *.* TO 'jqc_provisioner'@'localhost'; FLUSH PRIVILEGES;
|
||
```
|
||
|
||
### Bootstrap (run once)
|
||
|
||
```bash
|
||
# Set env (or add to /etc/jqc/control.env)
|
||
export CONTROL_DATABASE_URL='mysql+pymysql://jqc_control:<pw>@127.0.0.1/jqc_control'
|
||
export CONTROL_FERNET_KEY="$(python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
|
||
export PROVISION_DB_URL='mysql+pymysql://jqc_provisioner:<pw>@127.0.0.1/'
|
||
export TENANT_BASE_DOMAIN='jqc.app'
|
||
|
||
# Apply control schema + seed plans + first superadmin
|
||
alembic -c control/migrations/alembic.ini upgrade head
|
||
python -m control.cli seed
|
||
python -m control.cli create-superadmin --username admin --email you@example.com
|
||
|
||
# Adopt existing LT database as tenant-zero (no data moved)
|
||
python -m control.provision register-tenant-zero \
|
||
--slug lts --name "LT Services" --plan enterprise \
|
||
--db-host 127.0.0.1 --db-name <LT_DB> --db-user <LT_USER> --db-password '<pw>' \
|
||
--custom-domain jqc.ltservicesinc.com
|
||
|
||
# Provision a new tenant
|
||
python -m control.provision create-tenant \
|
||
--slug acme --name "Acme Corp" --plan pro --admin-email ops@acme.com
|
||
```
|
||
|
||
### Enable multi-tenancy
|
||
|
||
1. Add `MULTI_TENANT_ENABLED=true` to `/etc/jqc/control.env`
|
||
2. Configure wildcard DNS: `*.jqc.app A <SERVER_IP>`
|
||
3. Add Nginx wildcard server block (see `CLAUDE.md §19`)
|
||
4. Obtain wildcard TLS cert via DNS-01 challenge
|
||
5. `sudo systemctl daemon-reload && sudo systemctl restart jqc`
|
||
|
||
---
|
||
|
||
## License
|
||
|
||
See `LICENSE` for terms. |