import os from datetime import timedelta from dotenv import load_dotenv # Load .env from the project root (only takes effect locally; no-op in production # if variables are already set in the environment) load_dotenv() basedir = os.path.abspath(os.path.dirname(__file__)) def _require_env(key: str) -> str: """Return the value of a required environment variable, raising if absent.""" value = os.environ.get(key) if not value: raise RuntimeError( f"Required environment variable '{key}' is not set. " f"Add it to your .env file (development) or server environment (production)." ) return value class Config: # ── Security ──────────────────────────────────────────────────────────── # SECRET_KEY must be set externally — no insecure fallback. SECRET_KEY = _require_env('SECRET_KEY') # ── Database ──────────────────────────────────────────────────────────── # DATABASE_URL must be set externally — no hardcoded credentials. SQLALCHEMY_DATABASE_URI = _require_env('DATABASE_URL') SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ECHO = False # ── Multi-tenancy (MT-1) ───────────────────────────────────────────────── # Master switch. When False (default) the app behaves EXACTLY as the # single-tenant deployment: no Host resolution, every query uses # SQLALCHEMY_DATABASE_URI. Flip to True only after tenants are registered # in the control plane (see MULTI_TENANT_PLAN.md). The control plane env # vars (CONTROL_DATABASE_URL, CONTROL_FERNET_KEY) are only required when # this is True. MULTI_TENANT_ENABLED = os.environ.get( 'MULTI_TENANT_ENABLED', 'false' ).strip().lower() in ('1', 'true', 'yes', 'on') # Path prefixes that bypass the tenant gate even when enabled (e.g. health # checks). '/static/' is always exempt. Comma-separated in the environment. MULTI_TENANT_EXEMPT_PATHS = [ p.strip() for p in os.environ.get('MULTI_TENANT_EXEMPT_PATHS', '').split(',') if p.strip() ] # Apex domain for the public marketing/landing page. When MULTI_TENANT_ENABLED # is True, requests to this host (and its www. variant) are served the landing # page instead of being resolved to a tenant. Mirrors TENANT_BASE_DOMAIN used # by the provisioner to build . subdomains. TENANT_BASE_DOMAIN = os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app') # Per-tenant SQLAlchemy engine pool tuning (see MULTI_TENANT_PLAN.md §4). # MT-21: defaults lowered from 5/5 to 2/3. Backend connections scale as # workers × cached-tenants × (pool_size + max_overflow); at the old # defaults 8 workers × 32 tenants would reserve 2560 connections against a # MySQL max_connections that defaults to 151. TENANT_ENGINE_POOL_SIZE = int(os.environ.get('TENANT_ENGINE_POOL_SIZE', 2)) TENANT_ENGINE_MAX_OVERFLOW = int(os.environ.get('TENANT_ENGINE_MAX_OVERFLOW', 3)) TENANT_ENGINE_POOL_RECYCLE = int(os.environ.get('TENANT_ENGINE_POOL_RECYCLE', 1800)) # MT-21: hard cap on cached per-tenant engines per worker process. Beyond # this the least-recently-used engine is disposed and rebuilt on demand. # 0 disables the bound (previous behaviour — not recommended). TENANT_ENGINE_CACHE_MAX = int(os.environ.get('TENANT_ENGINE_CACHE_MAX', 32)) # ── File uploads ──────────────────────────────────────────────────────── UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads') MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} # ── Storage backend (R2 migration, MT-2) ──────────────────────────────── # 'local' (default) = files under app/static/uploads, served via url_for('static'). # 's3' = Cloudflare R2 / S3-compatible. Flip via env only, per tenant, in MT-8. # Object keys are tenant-prefixed inside the backend; DB paths never change. STORAGE_BACKEND = os.environ.get('STORAGE_BACKEND', 'local') # Cloudflare R2 — only used when STORAGE_BACKEND=s3 (inert until MT-8). R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL') R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID') R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY') R2_BUCKET = os.environ.get('R2_BUCKET') R2_PRESIGN_TTL = int(os.environ.get('R2_PRESIGN_TTL', '86400')) # seconds (24h) # When true, media_url HEADs the object and falls back to the legacy # unprefixed key, then a local static URL, if it's missing (belt-and-braces # during an early/partial cutover). Off by default — cutover is gated on # full verification, so objects exist. R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true' # ── Photo capture-time / geo overlay (MT-12) ──────────────────────────── # When true (default), POST /api/v1/photos/upload burns a timestamp + GPS # bar into the image before storing it. Set false to store raw uploads. # Global, not per-tenant: the overlay is evidence provenance, which every # tenant wants and none should be able to switch off from the UI. Promote to # a TenantSettings column only if a tenant ever has a real reason to opt out. PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true' # ── Web portal design (MT-16) ─────────────────────────────────────────── # Fallback design for users who have never chosen one. A stored # users.ui_theme ALWAYS wins, so this only affects accounts that have not # touched the switcher. # # Defaults to 'classic' — deliberately NOT ST's 'modern'. ST is # single-tenant and could decide for its own users; MT serves tenants who # never saw the A/B test, and flipping every user of every tenant to a new # UI on a migration is not a change to make on their behalf. New tenants can # be provisioned with DEFAULT_UI_THEME=modern, and any user can opt in from # the account menu at any time. DEFAULT_UI_THEME = os.environ.get('DEFAULT_UI_THEME', 'classic').strip().lower() # ── Photo retention (MT-13) ───────────────────────────────────────────── # Days after an issue is RESOLVED before its photo FILES are deleted by # POST /notifications/purge-old-photos. Unset (None) = the purge is a no-op. # Deliberately opt-in, not a default: this is a data-minimization policy the # operator chooses (GDPR Art. 5(1)(e) storage limitation), and some tenants # have longer contractual/audit retention of their own. Applies per tenant — # the cron is invoked once per tenant Host, like check-sla. PHOTO_RETENTION_DAYS = ( int(os.environ['PHOTO_RETENTION_DAYS']) if os.environ.get('PHOTO_RETENTION_DAYS') else None ) # ── Issue comment visibility (TEMPORARY — Aug 2026) ────────────────────── # True = every comment on an issue is visible to everyone, customers # included; the per-comment is_customer_visible flag is ignored # when READING. # False = phase22 behaviour — customers see only comments explicitly shared # with them. # # The flag is still WRITTEN on every comment, so flipping this back to # 'false' restores the old behaviour exactly, with no data to repair. # Set COMMENTS_VISIBLE_TO_ALL=false in the environment to revert. COMMENTS_VISIBLE_TO_ALL = os.environ.get( 'COMMENTS_VISIBLE_TO_ALL', 'true').lower() == 'true' # ── Session / cookies ─────────────────────────────────────────────────── PERMANENT_SESSION_LIFETIME = timedelta(hours=24) # Secure by default — subclasses must explicitly opt out for local dev. SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' # ── Mail ──────────────────────────────────────────────────────────────── # ── Application base URL (used in email links) ───────────────────────── APP_BASE_URL = os.environ.get('APP_BASE_URL', '') MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local') # ── Digest email secret token (used to authenticate cron trigger) ──────── DIGEST_SECRET = os.environ.get('DIGEST_SECRET') # ── Google Maps (used for GPS map on inspection view) ──────────────────── GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '') # ── Stripe / Billing (MT-8) ────────────────────────────────────────────── # BILLING_ENABLED=false by default — inert until explicitly flipped. # Flip to true only after STRIPE_* keys are set and plans have stripe_price_id. BILLING_ENABLED = os.environ.get('BILLING_ENABLED', 'false').strip().lower() in ('1', 'true', 'yes', 'on') STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY') STRIPE_PUBLISHABLE_KEY = os.environ.get('STRIPE_PUBLISHABLE_KEY') STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET') STRIPE_PRICE_STARTER = os.environ.get('STRIPE_PRICE_STARTER') STRIPE_PRICE_PRO = os.environ.get('STRIPE_PRICE_PRO') STRIPE_PRICE_ENTERPRISE = os.environ.get('STRIPE_PRICE_ENTERPRISE') MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') # ── SSL vs STARTTLS selection ──────────────────────────────────────────── # Port 465 = implicit SSL → MAIL_USE_SSL=True, MAIL_USE_TLS=False # Port 587 = STARTTLS → MAIL_USE_SSL=False, MAIL_USE_TLS=True # The two flags are mutually exclusive; setting both True breaks Flask-Mail. _mail_port = int(os.environ.get('MAIL_PORT') or 587) MAIL_PORT = _mail_port MAIL_USE_SSL = _mail_port == 465 MAIL_USE_TLS = not MAIL_USE_SSL # STARTTLS only when NOT using implicit SSL class DevelopmentConfig(Config): DEBUG = True SQLALCHEMY_ECHO = True # Allow HTTP cookies during local development (HTTP, not HTTPS) SESSION_COOKIE_SECURE = False class ProductionConfig(Config): DEBUG = False # Inherits SESSION_COOKIE_SECURE = True from Config — no override needed. config = { 'development': DevelopmentConfig, 'production': ProductionConfig, 'default': DevelopmentConfig, }