9.8 KiB
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. 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 — 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 (Phase 7) for the companion React Native / Expo mobile application; 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) |
| Flask-Mail (SMTP) | |
| ReportLab | |
| Frontend | Bootstrap 5, Chart.js, Jinja2 |
| Server | Gunicorn + Nginx |
| Mobile | React Native + Expo |
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
git clone <repo-url> jqc
cd jqc
python -m venv venv
source venv/bin/activate # Linux / macOS
# venv\Scripts\activate # Windows
2. Install dependencies
pip install -r requirements.txt
3. Create the database
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):
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
flask db upgrade
6. Start the development server
python run.py
The application will be available at http://localhost:5000.
Production Deployment
Gunicorn
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_URLis set in the environment, falling back to in-process memory for local development. In production with multiple Gunicorn workers, setREDIS_URL=redis://127.0.0.1:6379/0so rate-limit counters are shared across all workers. Theredispackage is included inrequirements.txt.
Nginx (recommended configuration)
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)
[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
Four background tasks require scheduled execution. All endpoints that require a token use DIGEST_SECRET.
# 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 | Read-only portal scoped to assigned facilities; receives notifications on their facilities |
Creating the First Admin Account
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
- Admin navigates to Customers → New Customer
- Enter the customer's Full Name and Email only — no username or password required from the admin
- The system auto-generates a temporary username and sends an invitation email with a secure 72-hour setup link
- Customer clicks the link and arrives at the account setup page where they choose their own username and set their password
- The account activates immediately and the customer can log in
- 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
# 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 Compatibility Notes
- ENUM changes require three steps: expand → migrate data → contract. Never skip steps.
CREATE INDEX IF NOT EXISTSis not supported on MySQL < 8.0.1. Useinformation_schema.statisticsexistence checks instead — seephase12_performance_indexes.pyfor 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 |
License
See LICENSE for terms.