diff --git a/CLAUDE.md b/CLAUDE.md index c3afefa..cefdd01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers working on this codebase. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. -> **Last reviewed:** June 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + **MT-0 through MT-3 multi-tenant control plane**) +> **Last reviewed:** June 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + **MT-0 through MT-7 complete; MT-8 flag-gated; MT-9 iOS pending**) --- @@ -29,7 +29,8 @@ 19. [Infrastructure](#19-infrastructure) 20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules) 21. [Change Philosophy](#21-change-philosophy) -22. [Multi-Tenant Architecture (MT-0 → MT-3)](#22-multi-tenant-architecture-mt-0--mt-3) +22. [Multi-Tenant Architecture (MT-0 → MT-7)](#22-multi-tenant-architecture-mt-0--mt-7) +23. [Coding Rules for AI Assistants](#23-coding-rules-for-ai-assistants) --- @@ -85,11 +86,13 @@ lt_janitorial_quality_control/ ├── app/ │ ├── __init__.py # Application factory — limiter, csrf, db (RoutingSession), mail, login_manager, init_tenancy │ ├── tenancy/ # MT-1 — tenant resolution + DB routing (inert unless MULTI_TENANT_ENABLED=true) -│ │ ├── __init__.py # public exports: RoutingSession, init_tenancy, TenantContext -│ │ ├── context.py # TenantContext frozen dataclass (g.tenant) +│ │ ├── __init__.py # public exports: RoutingSession, init_tenancy, TenantContext, feature_required, quota_soft_check +│ │ ├── context.py # TenantContext frozen dataclass (g.tenant) — MT-5: +9 plan fields │ │ ├── engine_cache.py # per-tenant SQLAlchemy engine cache + invalidate() -│ │ ├── middleware.py # init_tenancy() — before_request Host→tenant resolver -│ │ ├── resolver.py # resolve_tenant(host) → TenantContext | None +│ │ ├── gates.py # MT-5: @feature_required (hard 403) + @quota_soft_check (soft warn) +│ │ ├── middleware.py # init_tenancy() — before_request Host→tenant resolver + MT-4 impersonation override +│ │ ├── quota.py # MT-5: live quota counters (inspections/issues/users/facilities) +│ │ ├── resolver.py # resolve_tenant(host) → TenantContext | None — MT-5: loads plan fields │ │ └── routing.py # RoutingSession — routes db.session to g.tenant_engine │ ├── api/ # Mobile REST API │ │ ├── __init__.py # api_bp parent blueprint + register_api() @@ -108,9 +111,15 @@ lt_janitorial_quality_control/ │ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B) │ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19) │ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23) +│ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB) │ │ └── ... │ ├── routes/ │ │ ├── support.py # /support/* — AI chat, ticket submit/list/detail (Phase 23) +│ │ ├── tenant_settings.py # MT-7: /settings/* — branding, plan view, domain mgmt (@admin_required) +│ │ └── ... +│ ├── templates/ +│ │ ├── _quota_warning.html # MT-5: reusable quota exceeded banner partial +│ │ ├── tenant_settings/ # MT-7: branding.html, plan.html, domains.html │ │ └── ... │ ├── static/ │ │ └── uploads/ # UPLOAD_FOLDER root @@ -157,6 +166,14 @@ lt_janitorial_quality_control/ │ ├── seed.py # idempotent plan seeder │ ├── tenant_migrate.py # MT-2 — upgrade_tenant / bootstrap_tenant / chain_head CLI │ ├── time_utils.py # now_eastern() mirror (no app import) +│ ├── panel/ # MT-4 — superadmin control panel (standalone Flask app) +│ │ ├── __init__.py # create_panel_app() factory +│ │ ├── auth.py # /login, /logout (session-based, no Flask-Login) +│ │ ├── decorators.py # @superadmin_required +│ │ ├── impersonate.py # HMAC-SHA256 signed token generator/validator +│ │ ├── tenants.py # CRUD + plan/suspend/resume/domain/migrate/provision routes +│ │ ├── wsgi_panel.py # WSGI entry point — Gunicorn on port 8001 +│ │ └── templates/panel/ # base.html, login.html, tenants_list.html, tenant_detail.html, provision.html │ └── migrations/ # control Alembic chain (control{N}_…) │ └── versions/control0001_init.py ← HEAD └── ... @@ -187,10 +204,12 @@ lt_janitorial_quality_control/ | `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. | | `PROVISION_DB_URL` | MySQL account that can `CREATE DATABASE` / `CREATE USER` / `GRANT`. e.g. `mysql+pymysql://jqc_provisioner:pw@127.0.0.1/`. | | `TENANT_BASE_DOMAIN` | Apex domain for subdomains, e.g. `jqc.app`. Used by provisioner to build `.jqc.app`. | -| `MULTI_TENANT_EXEMPT_PATHS` | Comma-separated path prefixes that bypass the tenant gate (e.g. `/health`). `/static/` is always exempt. | +| `MULTI_TENANT_EXEMPT_PATHS` | Comma-separated path prefixes that bypass the tenant gate (e.g. `/health,/api/v1/discover`). `/static/` is always exempt. | | `TENANT_ENGINE_POOL_SIZE` | Per-tenant engine pool size (default 5). | | `TENANT_ENGINE_MAX_OVERFLOW` | Per-tenant pool max overflow (default 5). | | `TENANT_ENGINE_POOL_RECYCLE` | Pool recycle in seconds (default 1800). | +| `PANEL_SECRET_KEY` | Flask secret for the superadmin panel app (separate from `SECRET_KEY`). Generate: `python -c "import secrets; print(secrets.token_hex(32))"` | +| `PANEL_IMPERSONATE_KEY` | HMAC key for impersonation tokens. Must be identical between panel and main app env. Generate same way. | ### Email SSL Auto-Detection @@ -418,6 +437,8 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version | `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` | | `api_stats` | `/api/v1` | `GET /stats/dashboard` — inspector-scoped KPIs with severity breakdown (Phase B) | | `api_comments` | `/api/v1` | `GET /issues//comments`, `POST /issues//comments` (Phase D) | +| `api_discovery` | `/api/v1` | `GET /discover?subdomain=` or `?email=`, `GET /tenant` — MT-9 server side (public, no auth, exempt from tenant middleware) | +| `tenant_settings` | `/settings` | MT-7: `GET/POST /branding`, `GET /plan`, `GET /domains`, `POST /domains/request`, `POST /domains//delete` | --- @@ -666,7 +687,7 @@ limiter = Limiter( ## 17. Alembic Migration Chain -**Current HEAD:** `phase32_device_token_columns` (30 migrations total). +**Current HEAD:** `phase33_tenant_settings` (31 migrations total). **Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`. @@ -689,7 +710,8 @@ limiter = Limiter( → phase25_inspection_gps → phase26_issue_vendor → phase27_score_alerts - → ... → phase32_device_token_columns ← HEAD + → ... → phase32_device_token_columns + → phase33_tenant_settings ← HEAD ``` ### Fresh DB provisioning (multi-tenant) @@ -1152,7 +1174,7 @@ set -a; . /etc/jqc/control.env; set +a --- -## 22. Multi-Tenant Architecture (MT-0 → MT-3) +## 22. Multi-Tenant Architecture (MT-0 → MT-7) See `MULTI_TENANT_PLAN.md` for the full phased roadmap. This section summarises what is built and operational. @@ -1164,6 +1186,199 @@ Shared codebase + **database-per-tenant**. One Flask/Gunicorn process serves all Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`. +| Module | Purpose | +|---|---| +| `base.py` | `ControlBase`, `control_session()` context manager, engine from `CONTROL_DATABASE_URL` | +| `models.py` | `Plan`, `PlanFeature`, `Tenant`, `TenantDomain`, `Superadmin`, `ProvisioningJob`, `TenantAudit` | +| `crypto.py` | Fernet encrypt/decrypt for `tenants.db_password_enc` | +| `seed.py` | Idempotent plan seeder (Free / Starter / Pro / Enterprise) | +| `cli.py` | `seed`, `create-superadmin`, `list-plans` | +| `tenant_migrate.py` | `bootstrap_tenant()`, `upgrade_tenant()`, `chain_head()`, `current_revision()` + CLI | +| `provision.py` | `create_tenant()`, `register_tenant_zero()`, `delete_tenant()` + CLI | +| `panel/` | Standalone Flask app for superadmin — see MT-4 below | + +### Superadmin control panel (MT-4) + +Separate Flask WSGI app at `admin.jqc.app`, Gunicorn port 8001, `jqc-panel.service` systemd unit. + +**Critical Nginx ordering:** the `admin.jqc.app` server block must appear **before** the `*.jqc.app` wildcard block. If the wildcard catches `admin.jqc.app` first, requests go to port 8000 (main app) which returns 404 "Workspace not found". + +**Impersonation:** panel generates HMAC-SHA256 signed token (`PANEL_IMPERSONATE_KEY`, TTL 60 s) → redirects to `/auth/impersonate?token=` on the tenant → main app validates + sets `session['impersonating_tenant_id']` → tenancy middleware short-circuits Host resolution → "End impersonation" banner clears key. + +### Data-plane changes (`app/`) + +| File / Package | Change | +|---|---| +| `app/__init__.py` | `db = SQLAlchemy(session_options={'class_': RoutingSession})` + `init_tenancy(app)` + `inject_tenant_branding()` context processor + `hex_to_rgb` Jinja2 filter + `tenant_settings` blueprint | +| `config.py` | `MULTI_TENANT_ENABLED` (default `false`) + pool tunables | +| `app/tenancy/` | MT-1: resolution + routing. MT-5: `gates.py`, `quota.py`, extended `context.py` + `resolver.py` | +| `app/models/tenant_settings.py` | MT-7: per-tenant branding settings (one row per tenant DB) | +| `app/routes/tenant_settings.py` | MT-7: `/settings/` blueprint — branding, plan view, domain mgmt | +| `app/templates/tenant_settings/` | MT-7: branding, plan, domains templates | +| `app/templates/_quota_warning.html` | MT-5: reusable quota exceeded banner partial | +| `app/templates/base.html` | MT-7: tenant logo/name in navbar, CSS colour vars injection | + +### Plan tiers + +| Axis | Free | Starter | Pro | Enterprise | +|---|---|---|---|---| +| Max users | 3 | 15 | 50 | unlimited | +| Max facilities | 2 | 10 | 50 | unlimited | +| Inspections / month | 50 | 500 | 5 000 | unlimited | +| Issues / month | 50 | 500 | 5 000 | unlimited | +| Mobile API | ✗ | ✓ | ✓ | ✓ | +| Scheduled reports | ✗ | ✗ | ✓ | ✓ | +| Branding | ✗ | ✗ | ✓ | ✓ | +| Custom domain | ✗ | ✗ | ✓ | ✓ | +| Subdomain | ✓ | ✓ | ✓ | ✓ | + +Quota-exceed behaviour: **soft warn** — allow submit, flag for upgrade, never reject. +Feature gate (`@feature_required`): hard 403 — route is blocked. +Both decorators are inert when `MULTI_TENANT_ENABLED=false`. + +### Gate decorator stack order + +```python +@bp.route(...) +@login_required # 1 — must be authenticated +@admin_required # 2 — role check (if applicable) +@feature_required('x') # 3 — plan feature gate (hard 403) +@quota_soft_check('y') # 4 — quota warn (never blocks) +def my_route(): ... +``` + +### Tenant self-service (MT-7) — `/settings/` + +| Tab | Route | What it does | +|---|---|---| +| Branding | `/settings/branding` | Company name, logo upload, primary/accent colours, support email | +| Plan & Usage | `/settings/plan` | Read-only plan info, live quota progress bars | +| Domains | `/settings/domains` | List domains, request custom domain (TXT/CNAME verification), delete pending requests | + +`TenantSettings.get_or_default()` returns safe defaults when no row exists — zero migration burden for tenant-zero. + +### Provisioner MySQL account (required grants) + +```sql +GRANT ALL PRIVILEGES ON *.* TO 'jqc_provisioner'@'localhost' WITH GRANT OPTION; +GRANT CREATE USER ON *.* TO 'jqc_provisioner'@'localhost'; +FLUSH PRIVILEGES; +``` + +### CLI quick-reference + +```bash +# Source env first — CONTROL_DATABASE_URL not in interactive shell by default +set -a; . /etc/jqc/control.env; set +a + +# Control schema + plans + first superadmin (run once) +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 (run once, no data move) +python -m control.provision register-tenant-zero \ + --slug lts --name "LT Services" --plan enterprise \ + --db-host 127.0.0.1 --db-name --db-user --db-password '' \ + --custom-domain jqc.ltservicesinc.com --base-domain jqc.app + +# Provision a new tenant +python -m control.provision create-tenant \ + --slug acme --name "Acme Corp" --plan pro --admin-email ops@acme.com + +# Delete / deregister a tenant +python -m control.provision delete-tenant --slug ztest --drop-db --yes +python -m control.provision delete-tenant --slug lts --yes # adopted DB — no --drop-db + +# Migration status + upgrades +python -m control.tenant_migrate heads +python -m control.tenant_migrate current --tenant all +python -m control.tenant_migrate upgrade --tenant all # incremental (phase33+) +python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only +``` + +### Enable multi-tenancy (cutover sequence) + +```bash +# 1. Register LT as tenant-zero (see above) +# 2. Smoke-test routing (flag still off) +curl -sI -H "Host: lts.jqc.app" http://127.0.0.1:8000/ | head -2 +curl -sI -H "Host: ztest.jqc.app" http://127.0.0.1:8000/ | head -2 +# 3. Wildcard DNS: *.jqc.app A +# 4. Nginx config — admin.jqc.app block BEFORE *.jqc.app wildcard block (see §19) +# 5. Wildcard TLS cert (DNS-01, certbot) +# 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true +# 7. sudo systemctl daemon-reload && sudo systemctl restart jqc jqc-panel +``` + +### Known MT-specific gotchas (rules 71–83) + +| # | Rule | +|---|---| +| 71 | `CONTROL_FERNET_KEY` must be identical between CLI sessions and Gunicorn — use the same `/etc/jqc/control.env` | +| 72 | `MULTI_TENANT_ENABLED` must be flipped only after tenant-zero is registered and domains verified | +| 73 | Tenant DB passwords use `_gen_password()` — MySQL `validate_password` MEDIUM requires lower+upper+digit+special | +| 74 | `bootstrap_tenant()` for fresh DBs; `upgrade_tenant()` for incremental (phase33+) | +| 75 | `delete_tenant()` + `_add_domains()` are retry-safe | +| 76 | `register-tenant-zero` never bootstraps and never drops the DB | +| 77 | `CONTROL_DATABASE_URL` not in interactive shell — always `set -a; . /etc/jqc/control.env; set +a` before CLI | +| 78 | Nginx `admin.jqc.app` must be a separate `server {}` block before `*.jqc.app` — exact name wins only in its own block | +| 79 | `jqc.app` apex has no registered tenant — Nginx redirects to `lts.jqc.app` at the server level | +| 80 | `@feature_required` before `@quota_soft_check` in decorator stack — no point counting if feature is blocked | +| 81 | `TenantSettings.get_or_default()` returns a transient (non-persisted) default instance — `db.session.add(row)` required before first save | +| 82 | `inject_tenant_branding()` context processor wraps the DB call in try/except — branding failure must never break page rendering | +| 83 | `hex_to_rgb` Jinja2 filter required for `--bs-primary-rgb` CSS var — register in `app/__init__.py` | + +--- + +## 23. Coding Rules for AI Assistants + +These rules apply to every change made to this codebase, without exception. + +### Before Writing Any Code + +**Rule 1 — Read the actual file on disk first.** +Use the view or read tool on every file that will be modified. Never rely on output from a previous turn — a prior edit invalidates earlier view output. Always re-read before a second edit to the same file. + +**Rule 2 — Trace the full request path.** +For any bug, follow the request from the browser through: decorator → form validation → route logic → DB write → template render. Identify the exact layer where the failure occurs before proposing a fix. + +**Rule 3 — Find the root cause. No assumptions.** +"It might be X" is not sufficient. Confirm X by reading the relevant code. State the root cause explicitly in the response. + +**Rule 4 — Check both layers of enforcement.** +Feature gates and quota checks must be applied in BOTH web routes AND `/api/v1` API endpoints. A web-only check is bypassable by the iPad app. + +### Making Changes + +**Rule 5 — Make the smallest possible change.** +Do not rewrite surrounding code. Do not rename variables, restructure functions, or reformat blocks unless explicitly requested. + +**Rule 6 — Never remove functionality** that was not explicitly asked to be removed. + +**Rule 7 — Preserve all route names, function names, and variable names** unless explicitly directed otherwise. + +**Rule 8 — Keep all log handlers.** + +**Rule 9 — One atomic commit per logical transaction.** + +### Before Providing the Output + +**Rule 10 — Verify the fix in the file on disk.** +After applying a patch, read the changed section back and confirm the intended change is present, no surrounding code was accidentally removed, and import statements are consistent. + +**Rule 11 — Check for introduced bugs.** +Ask: Does this change break any other code path that uses the modified function, field, or query? + +**Rule 12 — State the root cause explicitly** in the deployment instructions. "Changed X to Y" is not enough — explain *why* the old code failed and *how* the fix resolves it. + +**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified). + +**Rule 14 — Migrations are required for any schema change.** +Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase33_tenant_settings`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL. + +Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`. + | Module | Purpose | |---|---| | `base.py` | `ControlBase`, `control_session()` context manager, engine from `CONTROL_DATABASE_URL` | diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md index 4f8c9e6..9e9abac 100644 --- a/MULTI_TENANT_PLAN.md +++ b/MULTI_TENANT_PLAN.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS. > **Companion to:** `CLAUDE.md` (single-tenant architecture reference). -> **Status:** Planning. No code written yet. Existing single-tenant deploy stays live and untouched until cutover. +> **Status:** MT-0 through MT-7 complete and deployed. MT-8 flag-gated (future). MT-9 (iOS) pending. --- @@ -74,7 +74,7 @@ tenant_audit -- superadmin actions (separate from per-ten id, superadmin_id, action, tenant_id, details, ip_address, created_at ``` -Submission quotas (inspections/issues per month) are enforced by **live-counting current-period rows in the tenant DB** at submit time (cheap with an index on the date column). No separate counter table required; always accurate. A cached `usage_counters` table is an optional later optimization if submit-path counts ever show up in profiling. +Submission quotas (inspections/issues per month) are enforced by **live-counting current-period rows in the tenant DB** at submit time (cheap with an index on the date column). No separate counter table required; always accurate. --- @@ -82,7 +82,7 @@ Submission quotas (inspections/issues per month) are enforced by **live-counting **Resolution** (`before_request`): `host = request.host.split(':')[0]` → look up `tenant_domains.domain == host` where `verified AND tenant.status='active'` → load `tenant` + `plan` into `g`. Unknown/unverified/suspended host → branded error/landing page (no tenant DB touched). -**Routing without rewriting every `db.session` call** — use a routing Session so the entire existing codebase keeps using `db.session` unchanged (preserves Change-Philosophy Rules 5/7): +**Routing without rewriting every `db.session` call** — use a routing Session so the entire existing codebase keeps using `db.session` unchanged: ```python # app/tenancy/routing.py @@ -91,31 +91,22 @@ from flask import g class RoutingSession(Session): def get_bind(self, mapper=None, clause=None, **kw): - # Tenant models (app.db.Model) bind to the per-request tenant engine. - # The control plane is a SEPARATE Base/engine/session (control/base.py), - # so it never passes through this session — no special-casing needed here. return getattr(g, 'tenant_engine', None) or _default_engine ``` > **MT-0 refinement (implemented):** the control plane uses its own > `ControlBase` + engine + session (`control/base.py`), **not** `db.Model` with -> a `__control_plane__` flag. Stronger isolation, zero metadata mixing with the -> tenant schema, and the control package imports nothing from `app/`. The -> `RoutingSession` (MT-1) therefore only handles tenant models. +> a `__control_plane__` flag. The `RoutingSession` (MT-1) therefore only handles tenant models. ```python # app/__init__.py (one-line change to the existing db init) db = SQLAlchemy(session_options={'class_': RoutingSession}) ``` -```python -# before_request -g.tenant_engine = _engine_cache.get_or_create(tenant) # create_engine(tenant.db_uri) -``` +- `_engine_cache`: `dict[tenant_id -> Engine]`, lazily built, small pools + `pool_recycle`. +- **Net code change to existing models/routes: zero.** -- `_engine_cache`: `dict[tenant_id -> Engine]`, lazily built. Use small pools + `pool_recycle`; switch to `NullPool` if tenant count grows large (connection-count = Σ tenant pools). Documented scaling lever. -- The **control plane is a separate Base/engine/session** (`control/base.py`); it never routes through `RoutingSession`. See §3 MT-0 refinement. -- **Net code change to existing models/routes: zero.** They keep importing `from app import db` and calling `db.session`. +**MT-5 addition:** Plan limits and feature flags are loaded into `TenantContext` by the resolver in the same control-DB session — zero extra queries per request. `g.tenant.allow_mobile_api`, `g.tenant.max_users`, etc. are available everywhere. --- @@ -123,16 +114,20 @@ g.tenant_engine = _engine_cache.get_or_create(tenant) # create_engine(tenant.d Two independent Alembic chains: -1. **Tenant schema** — the existing chain (current HEAD on disk: **`phase27_score_alerts`**; note `CLAUDE.md` text says phase23 — stale). Runs per-tenant DB. New tenant features continue this chain as `phase28_…`, `phase29_…` per existing naming + `down_revision` → current HEAD rule. -2. **Control schema** — new env, chain `control{N}_…`, runs once against `jqc_control`. +1. **Tenant schema** — existing chain (HEAD: **`phase33_tenant_settings`**). Runs per-tenant DB. New tenant features continue as `phase34_…` per existing naming. +2. **Control schema** — chain `control{N}_…`, runs once against `jqc_control`. HEAD: `control0001_init`. -CLI added: `flask tenant db upgrade --tenant ` loops tenants, sets `sqlalchemy.url` to each tenant URI, runs `command.upgrade(cfg, 'head')`, writes resulting head back to `tenants.alembic_head`. All existing migrations are re-run-safe (Rule 14), so this is idempotent across the fleet. +**CLI (always source env first):** +```bash +set -a; . /etc/jqc/control.env; set +a +python -m control.tenant_migrate upgrade --tenant all +``` -Deploy ordering rule carries over (CLAUDE.md Rule 23): run tenant migrations **before** shipping app code that references new columns. +Deploy ordering rule: run tenant migrations **before** shipping app code that references new columns. --- -## 6. Plan-tier matrix (starting point — tune in §9) +## 6. Plan-tier matrix | Axis | Free | Starter | Pro | Enterprise | |---|---|---|---|---| @@ -146,37 +141,88 @@ Deploy ordering rule carries over (CLAUDE.md Rule 23): run tenant migrations **b | Custom domain | ✗ | ✗ | ✓ | ✓ | | Subdomain (`*.jqc.app`) | ✓ | ✓ | ✓ | ✓ | -Enforcement: `@feature_required('mobile_api')` reads `g.tenant.plan` and **blocks** disabled features. Quota checks (`@quota_check('inspections')`) are **soft** — over-limit submits still succeed but the response/UI flags an upgrade prompt and the event is recorded; submits are never rejected. **Feature + quota checks must live in BOTH web routes and `/api/v1` endpoints** — the iPad submits via API, so a web-only check is bypassable (mirrors existing dual-enforcement Rules 39/56/57). +Enforcement: `@feature_required('mobile_api')` hard-blocks (403) disabled features. `@quota_soft_check('inspections')` sets `g.quota_warning` but **never rejects** — over-limit submits succeed, UI shows upgrade prompt. Both decorators are **inert** when `MULTI_TENANT_ENABLED=false`. + +Feature + quota checks live in **both** web routes and `/api/v1` endpoints (iPad submits via API — web-only check is bypassable). --- ## 7. Phased roadmap -Each phase additive; existing tenant-zero traffic keeps working throughout. +**MT-0 — Control-plane scaffold. ✅ DONE.** +Self-contained `control/` package: own `ControlBase` + engine + session, own Alembic chain (`control0001_init`), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into `app/`. -**MT-0 — Control-plane scaffold. ✅ DONE.** Self-contained `control/` package at repo root: own `ControlBase` + engine + session, own Alembic chain (`control0001_init`), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into `app/` — existing app untouched. +**MT-1 — Tenant resolution + routing. ✅ DONE.** +`app/tenancy/` package: `RoutingSession`, `resolve_tenant()`, `engine_cache`, `TenantContext`, `init_tenancy()` before_request hook + branded 404. Gated behind `MULTI_TENANT_ENABLED=false` — fully inert until flipped. -**MT-1 — Tenant resolution + routing. ✅ DONE.** `app/tenancy/` package: `routing.py` (`RoutingSession` subclassing the Flask-SQLAlchemy session), `resolver.py` (Host→tenant via control plane, lazy import), `engine_cache.py` (per-tenant engines), `context.py` (`TenantContext`), `middleware.py` (`init_tenancy` before_request hook + branded unknown-host 404). Edits: `db` init in `app/__init__.py` (+`init_tenancy(app)` call) and `MULTI_TENANT_ENABLED` + pool flags in `config.py`. Gated behind `MULTI_TENANT_ENABLED` (default False) — fully inert until flipped. +**MT-2 — Per-tenant migration runner. ✅ DONE.** +`migrations_tenant/env.py` + `control/tenant_migrate.py` (`upgrade_tenant`, `bootstrap_tenant`, `chain_head`, CLI). Guarded squashed baseline `0003_add_user_active` restores chain root. Fresh DBs use `bootstrap_tenant` (baseline → stamp head, skips unguarded phase migrations). Incremental upgrades use `upgrade_tenant` (phase33+). -**MT-2 — Per-tenant migration runner. ✅ DONE (with blocker found).** Standalone `migrations_tenant/env.py` (reads URL from config, reuses `migrations/versions` via `version_locations`, no Flask) + `control/tenant_migrate.py` (`upgrade_tenant()`, `current_revision()`, `chain_head()`, CLI: `python -m control.tenant_migrate upgrade|current|heads --tenant `). Records `tenants.alembic_head` + a `ProvisioningJob('migrate')` per run. Existing `migrations/env.py` untouched (normal `flask db` still works). +> **⚠ Blocker found by MT-2 — RESOLVED.** Chain had no base; 14/30 phase migrations unguarded. Fixed by guarded squashed baseline. Fresh DB provisioning must use `bootstrap_tenant`, never raw `flask db upgrade`. -> **⚠ Blocker found by MT-2 — RESOLVED.** The live `migrations/versions` chain had **no base**: `phase1_projects_roles.down_revision = '0003_add_user_active'` pointed at a missing revision, and 14/30 phase migrations are not idempotent. **Fix:** a guarded squashed baseline `migrations/versions/0003_add_user_active.py` (`down_revision = None`) that recreates the full current schema (generated from the models, 25 tables, INFORMATION_SCHEMA-guarded) — restoring the chain root. Fresh tenants are built via `bootstrap_tenant()` = upgrade to the baseline **then `stamp head`**, so the non-idempotent phase migrations are never replayed. Existing DBs (LT) are at a later head, so the baseline is treated as applied ancestry and never runs. Ongoing migrations (phase33+, which MUST be guarded) apply incrementally to all tenants via `upgrade_tenant()`. -> -> **Operational note:** any *fresh* database (including a new dev DB) must use the bootstrap flow, not a naive `flask db upgrade` from empty, because the historical phase replay still hits the unguarded migrations. Cross-check the baseline against `mysqldump --no-data` of LT and test `bootstrap` on a scratch MySQL before going live. +**MT-3 — Provisioning service. ✅ DONE.** +`control/provision.py`: `create_tenant()` (DB + user + schema + admin seed + domain), `register_tenant_zero()` (adopt LT DB in place), `delete_tenant()`. Fernet-encrypted creds, `provisioning_jobs` logged. CLI: `python -m control.provision`. -**MT-3 — Provisioning service. ✅ DONE.** `control/provision.py`: `create_tenant()` (admin account from `PROVISION_DB_URL` creates per-tenant DB + least-privilege user + grant → `bootstrap_tenant` builds schema + stamps head → `seed_admin` inserts the first admin with a set-password token → registers subdomain + optional custom domain → status active; best-effort rollback drops the DB/user + tenant row on failure). `register_tenant_zero()` adopts an existing DB in place (no DB/user creation, no bootstrap; reads its current head, maps `lts.` + LT's live domain as a verified custom domain — zero data movement). `delete_tenant()` removes control records, optional guarded DB drop (refuses non-provisioner-named DBs, protecting tenant-zero). CLI: `python -m control.provision create-tenant|register-tenant-zero|delete-tenant`. Admin setup link uses `/customers/set-password/` (the route that sets `password_set=True`). Creds Fernet-encrypted; `provisioning_jobs`-logged. +**MT-4 — Superadmin control panel. ✅ DONE.** +Standalone Flask app at `admin.jqc.app` (`control/panel/`). WSGI entry: `control/panel/wsgi_panel.py` → Gunicorn on port 8001 (`jqc-panel.service`). Separate Nginx server block — **must appear before the `*.jqc.app` wildcard block** or Nginx routes `admin.jqc.app` to port 8000 (main app). -**MT-4 — Superadmin control panel.** Tenant CRUD, plan assign, suspend/resume, domain mgmt, migration status, impersonation (scoped login into a tenant for support). At `admin.jqc.app`, separate blueprint, superadmin-gated. +Routes: tenant list/detail, plan change, suspend/resume, domain CRUD (add/verify/delete), migration status + upgrade trigger, provision new tenant, impersonation. -**MT-5 — Plans + quota/feature gating.** `@feature_required`, `@quota_check`; new cross-tenant `superadmin` (control plane). Existing tenant `admin` = top role within a tenant (unchanged ENUM). +**Impersonation flow:** +1. Panel generates HMAC-SHA256 signed token (`PANEL_IMPERSONATE_KEY`, TTL 60 s). +2. Redirects to `https:///auth/impersonate?token=`. +3. Main app validates token, sets `session['impersonating_tenant_id']`. +4. Tenancy middleware reads this key and short-circuits Host resolution. +5. "End impersonation" banner clears key, redirects back to `admin.jqc.app`. -**MT-6 — Custom domain + TLS.** Wildcard cert for `*.jqc.app`; Caddy on-demand TLS in front for arbitrary custom domains. Domain-verification flow (TXT/CNAME) before activation. +Required env vars (add to `/etc/jqc/control.env`): +``` +PANEL_SECRET_KEY= +PANEL_IMPERSONATE_KEY= +``` -**MT-7 — Tenant self-service.** Tenant-admin: branding (logo/name/colors), domain request, plan view, in-plan feature toggles. User mgmt auto tenant-scoped by routing. +**MT-5 — Plans + quota/feature gating. ✅ DONE.** +`app/tenancy/quota.py` — live counters (inspections/issues this month, total users/facilities) against tenant DB. +`app/tenancy/gates.py` — `@feature_required(key)` (hard 403) and `@quota_soft_check(axis)` (sets `g.quota_warning`, never rejects). +`app/tenancy/context.py` — `TenantContext` extended with 9 plan fields (all default to unlimited/True → single-tenant unchanged). +`app/tenancy/resolver.py` — loads `plan` in the same control session, populates `TenantContext` plan fields. +`app/templates/_quota_warning.html` — reusable upgrade-prompt banner partial. -**MT-8 — Billing (future).** Stripe per-plan; lifecycle (trial/suspend/dunning). Flag-gated. +Gated routes: -**MT-9 — iOS multi-tenant.** Replace hardcoded `ServerOption` enum (`Utils/Constants.swift`) with onboarding: enter subdomain or work-email → discovery endpoint resolves base URL → store per-tenant in Keychain. All `/api/v1` calls unchanged (server resolves tenant by Host). Tenant-zero keeps `jqc.ltservicesinc.com` as a custom domain so existing builds keep working through rollout. +| Route | Gate | +|---|---| +| `inspections.start()` | `@quota_soft_check('inspections')` | +| `issues.create()` | `@quota_soft_check('issues')` | +| `auth.create_user()` | `@quota_soft_check('users')` | +| `facilities.create_facility()` | `@quota_soft_check('facilities')` | +| `scheduled_reports.index()` + `create()` | `@feature_required('scheduled_reports')` | +| `api.create_inspection()` | `@feature_required('mobile_api')` + `@quota_soft_check('inspections')` | +| `api.create_issue()` | `@feature_required('mobile_api')` + `@quota_soft_check('issues')` | + +Decorator stack order: `@login_required` → `@role_required` → `@feature_required` → `@quota_soft_check`. + +**MT-6 — Custom domain + TLS. ⚙ INFRASTRUCTURE ONLY — no Python deliverables.** +Wildcard cert `*.jqc.app` via DNS-01 challenge (certbot + DNS plugin). Custom-domain TLS via Caddy on-demand TLS. Domain-verification flow (TXT/CNAME) already in MT-7 self-service UI; superadmin marks `verified=True` in the control panel after DNS check. When Caddy is deployed, update Nginx to pass custom domains to Caddy rather than directly to port 8000. + +**MT-7 — Tenant self-service. ✅ DONE.** +`app/models/tenant_settings.py` — `TenantSettings` model, one row per tenant DB, `get_or_default()` returns transient defaults when no row exists (zero migration burden for existing tenants). +`app/routes/tenant_settings.py` — blueprint at `/settings/`, `@admin_required`. +`app/templates/tenant_settings/` — `branding.html`, `plan.html`, `domains.html`. +Migration: `phase33_tenant_settings` (INFORMATION_SCHEMA guarded, safe to re-run). + +Branding injection: `inject_tenant_branding()` context processor in `app/__init__.py` pushes `tenant_branding` into every template. `base.html` patches: navbar brand reads logo/name from `tenant_branding`; CSS vars `--bs-primary`, `--jqc-accent` injected via inline `