feat: subdomain routing por tenant — refac-xxx.nexusautoparts.com

- Nginx wildcard config: *.nexusautoparts.com routes to POS app with X-Tenant-Subdomain header
- middleware_tenant.py: resolves subdomain -> tenant_id via nexus_master.tenants.subdomain
- auth_bp: login and employee list endpoints accept tenant from subdomain, URL param, or body
- login.html: auto-detects tenant from subdomain, shows business name, falls back to ?tenant=ID
- tenant_manager: generates subdomain slug from business name on provision_tenant()
- Migration v1.2: adds subdomain column + unique index to tenants table
- setup-nginx.sh: one-command install script for the nginx config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 07:16:49 +00:00
parent bdbbc78a15
commit 6628f2deef
8 changed files with 360 additions and 20 deletions

58
nginx/nexus-pos.conf Normal file
View File

@@ -0,0 +1,58 @@
# Wildcard subdomain routing for Nexus POS
# DNS: *.nexusautoparts.com -> server IP (Cloudflare wildcard)
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=pos_login:10m rate=10r/s;
# Upstream backends
upstream nexus_main {
server 127.0.0.1:5000;
}
upstream nexus_pos {
server 127.0.0.1:5001;
}
# Main site (no subdomain)
server {
listen 80;
server_name nexusautoparts.com www.nexusautoparts.com;
location / {
proxy_pass http://nexus_main;
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;
}
}
# POS subdomains (wildcard)
server {
listen 80;
server_name ~^(?<tenant>.+)\.nexusautoparts\.com$;
# Security headers
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
location / {
proxy_pass http://nexus_pos;
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;
proxy_set_header X-Tenant-Subdomain $tenant;
}
# Rate limit login endpoint
location /pos/api/auth/login {
limit_req zone=pos_login burst=5 nodelay;
proxy_pass http://nexus_pos;
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;
proxy_set_header X-Tenant-Subdomain $tenant;
}
}

24
nginx/setup-nginx.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Setup Nginx config for Nexus POS subdomain routing
# Usage: sudo bash setup-nginx.sh
set -euo pipefail
CONF_SRC="/home/Autopartes/nginx/nexus-pos.conf"
CONF_DEST="/etc/nginx/sites-available/nexus-pos"
echo "==> Copying nginx config..."
sudo cp "$CONF_SRC" "$CONF_DEST"
echo "==> Creating symlink in sites-enabled..."
sudo ln -sf "$CONF_DEST" /etc/nginx/sites-enabled/
echo "==> Testing nginx config..."
if sudo nginx -t; then
echo "==> Config OK. Reloading nginx..."
sudo systemctl reload nginx
echo "==> Done. Subdomain routing active."
else
echo "!!! Nginx config test failed. NOT reloading."
exit 1
fi

View File

@@ -3,6 +3,17 @@ from flask import Flask
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
# Tenant subdomain resolver (before every request)
from middleware_tenant import resolve_tenant
app.before_request(resolve_tenant)
# ─── PWA: Service Worker must be served from /pos/ scope ──────
@app.route('/pos/sw.js')
def pos_sw():
from flask import send_from_directory
return send_from_directory('static/pwa', 'sw.js',
mimetype='application/javascript')
# Register blueprints # Register blueprints
from blueprints.auth_bp import auth_bp from blueprints.auth_bp import auth_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
@@ -31,16 +42,22 @@ def create_app():
from blueprints.accounting_bp import accounting_bp from blueprints.accounting_bp import accounting_bp
app.register_blueprint(accounting_bp) app.register_blueprint(accounting_bp)
from blueprints.chat_bp import chat_bp
app.register_blueprint(chat_bp)
# Health check # Health check
@app.route('/pos/health') @app.route('/pos/health')
def health(): def health():
return {'status': 'ok'} return {'status': 'ok'}
from flask import render_template, send_from_directory from flask import render_template, send_from_directory, jsonify, g
@app.route('/pos/login') @app.route('/pos/login')
def pos_login(): def pos_login():
return render_template('login.html') return render_template('login.html',
tenant_id=getattr(g, 'tenant_id', None),
tenant_name=getattr(g, 'tenant_name', None),
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
@app.route('/pos/catalog') @app.route('/pos/catalog')
def pos_catalog(): def pos_catalog():
@@ -82,6 +99,46 @@ def create_app():
def pos_static(filename): def pos_static(filename):
return send_from_directory('static', filename) return send_from_directory('static', filename)
# ─── Sync: full inventory for offline cache ───────────────────
from middleware import require_auth as _require_auth
@app.route('/pos/api/sync/inventory', methods=['GET'])
@_require_auth()
def sync_full_inventory():
"""Download full inventory for offline cache."""
from tenant_db import get_tenant_conn
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
branch_id = g.branch_id
cur.execute("""
SELECT i.id, i.part_number, i.barcode, i.name, i.brand,
i.unit, i.price_1, i.price_2, i.price_3, i.tax_rate,
COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN (
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.is_active = true AND i.branch_id = %s
ORDER BY i.name
""", [branch_id])
items = []
for r in cur.fetchall():
items.append({
'item_id': r[0], 'sku': r[1], 'barcode': r[2],
'name': r[3], 'brand': r[4], 'unit': r[5],
'price_1': float(r[6]) if r[6] else 0,
'price_2': float(r[7]) if r[7] else 0,
'price_3': float(r[8]) if r[8] else 0,
'tax_rate': float(r[9]) if r[9] else 0.16,
'stock': r[10]
})
cur.close()
conn.close()
return jsonify({'items': items, 'count': len(items)})
return app return app
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -5,7 +5,7 @@ import jwt
import bcrypt import bcrypt
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify, g
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
from tenant_db import get_tenant_conn, get_master_conn from tenant_db import get_tenant_conn, get_master_conn
@@ -48,9 +48,16 @@ def _record_attempt(device_id, success):
@auth_bp.route('/login', methods=['POST']) @auth_bp.route('/login', methods=['POST'])
def login_pin(): def login_pin():
"""Login with tenant_id + PIN + device_id.""" """Login with tenant_id + PIN + device_id.
tenant_id can come from:
1. Subdomain (resolved by middleware_tenant into g.tenant_id)
2. POST body tenant_id field
3. Both (subdomain takes precedence)
"""
data = request.get_json() or {} data = request.get_json() or {}
tenant_id = data.get('tenant_id') # Subdomain-resolved tenant takes priority over body param
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
pin = data.get('pin', '') pin = data.get('pin', '')
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown')) device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
# Optional: branch_id from the device for PIN search optimization # Optional: branch_id from the device for PIN search optimization
@@ -158,10 +165,24 @@ def login_pin():
@auth_bp.route('/employees/<int:tenant_id>', methods=['GET']) @auth_bp.route('/employees/<int:tenant_id>', methods=['GET'])
def list_login_employees(tenant_id): @auth_bp.route('/employees', methods=['GET'])
"""Public endpoint: list employees for the login screen (names + roles only, no sensitive data).""" def list_login_employees(tenant_id=None):
"""Public endpoint: list employees for the login screen (names + roles only, no sensitive data).
tenant_id comes from URL path, subdomain, or ?tenant= param.
"""
# Resolve tenant_id: URL path > subdomain > query param
tid = tenant_id or getattr(g, 'tenant_id', None)
if not tid:
try:
tid = int(request.args.get('tenant', 0))
except (ValueError, TypeError):
pass
if not tid:
return jsonify({'error': 'Tenant not specified'}), 400
try: try:
conn = get_tenant_conn(tenant_id) conn = get_tenant_conn(tid)
except ValueError: except ValueError:
return jsonify({'error': 'Tenant not found'}), 404 return jsonify({'error': 'Tenant not found'}), 404

116
pos/middleware_tenant.py Normal file
View File

@@ -0,0 +1,116 @@
# /home/Autopartes/pos/middleware_tenant.py
"""Subdomain-based tenant resolver middleware for Nexus POS.
Routes like refac-lopez.nexusautoparts.com are resolved to a tenant_id
via the `tenants.subdomain` column in nexus_master.
Resolution order:
1. X-Tenant-Subdomain header (set by nginx)
2. Host header subdomain extraction (fallback for dev)
3. ?tenant=ID URL parameter (legacy / direct access)
"""
import re
from flask import request, g, redirect
from tenant_db import get_master_conn
# Domains that should NOT be treated as tenant subdomains
_RESERVED = {'www', 'api', 'admin', 'mail', 'staging', 'dev'}
# Cache: subdomain -> {tenant_id, name} (cleared on app restart)
_subdomain_cache = {}
def _extract_subdomain():
"""Extract tenant subdomain from request. Returns subdomain string or None."""
# 1. Nginx header (most reliable in production)
sub = request.headers.get('X-Tenant-Subdomain', '').strip().lower()
if sub and sub not in _RESERVED:
return sub
# 2. Parse from Host header (dev fallback)
host = request.host.split(':')[0].lower() # strip port
# Match: <subdomain>.nexusautoparts.com or <subdomain>.localhost
parts = host.split('.')
if len(parts) >= 3:
# e.g. refac-lopez.nexusautoparts.com -> refac-lopez
candidate = parts[0]
if candidate not in _RESERVED:
return candidate
elif len(parts) == 2 and parts[1] == 'localhost':
# e.g. refac-lopez.localhost for local dev
candidate = parts[0]
if candidate not in _RESERVED:
return candidate
return None
def _lookup_tenant_by_subdomain(subdomain):
"""Look up tenant_id and name from subdomain. Returns dict or None."""
if subdomain in _subdomain_cache:
return _subdomain_cache[subdomain]
try:
conn = get_master_conn()
cur = conn.cursor()
cur.execute(
"SELECT id, name FROM tenants WHERE subdomain = %s AND is_active = true",
(subdomain,)
)
row = cur.fetchone()
cur.close()
conn.close()
if row:
result = {'tenant_id': row[0], 'name': row[1]}
_subdomain_cache[subdomain] = result
return result
except Exception:
pass
return None
def clear_subdomain_cache():
"""Clear the subdomain lookup cache (call after tenant updates)."""
_subdomain_cache.clear()
def resolve_tenant():
"""Flask before_request handler: resolve tenant from subdomain or URL param.
Sets on flask.g:
- g.tenant_id (int or None)
- g.tenant_name (str or None)
- g.tenant_subdomain (str or None)
"""
g.tenant_id = None
g.tenant_name = None
g.tenant_subdomain = None
# Skip for static files and health check
if request.path.startswith('/pos/static/') or request.path == '/pos/health':
return
subdomain = _extract_subdomain()
if subdomain:
tenant = _lookup_tenant_by_subdomain(subdomain)
if tenant:
g.tenant_id = tenant['tenant_id']
g.tenant_name = tenant['name']
g.tenant_subdomain = subdomain
return
else:
# Unknown subdomain: redirect to main site (only for page loads, not API)
if not request.path.startswith('/pos/api/'):
return redirect('https://nexusautoparts.com', code=302)
# Fallback: ?tenant=ID URL parameter
tenant_param = request.args.get('tenant')
if tenant_param:
try:
g.tenant_id = int(tenant_param)
except (ValueError, TypeError):
pass

View File

@@ -0,0 +1,7 @@
-- v1.2: Add subdomain column to tenants table (nexus_master)
-- This migration runs against the MASTER database, not tenant databases.
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS subdomain VARCHAR(100) UNIQUE;
CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain);
COMMENT ON COLUMN tenants.subdomain IS 'URL subdomain for tenant POS access, e.g. refac-lopez -> refac-lopez.nexusautoparts.com';

View File

@@ -2,6 +2,8 @@
"""Create and manage tenant databases.""" """Create and manage tenant databases."""
import os import os
import re
import unicodedata
import psycopg2 import psycopg2
from psycopg2 import sql from psycopg2 import sql
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
@@ -12,6 +14,24 @@ MIGRATIONS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'migra
SEED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'seed') SEED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'seed')
def generate_subdomain(name):
"""Generate a URL-safe subdomain from a business name.
Examples:
'Refaccionaria López' -> 'refaccionaria-lopez'
'Auto Parts MX #3' -> 'auto-parts-mx-3'
"""
# Normalize unicode (strip accents)
nfkd = unicodedata.normalize('NFKD', name)
ascii_name = nfkd.encode('ascii', 'ignore').decode('ascii')
# Lowercase, replace non-alphanumeric with hyphens
slug = re.sub(r'[^a-z0-9]+', '-', ascii_name.lower()).strip('-')
# Collapse multiple hyphens
slug = re.sub(r'-{2,}', '-', slug)
# Truncate to 100 chars (column limit)
return slug[:100]
def ensure_master_tables(): def ensure_master_tables():
"""Create tenants/subscriptions/schema_version tables in nexus_master if missing.""" """Create tenants/subscriptions/schema_version tables in nexus_master if missing."""
conn = get_master_conn() conn = get_master_conn()
@@ -21,12 +41,20 @@ def ensure_master_tables():
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL, name VARCHAR(200) NOT NULL,
db_name VARCHAR(100) UNIQUE NOT NULL, db_name VARCHAR(100) UNIQUE NOT NULL,
subdomain VARCHAR(100) UNIQUE,
rfc VARCHAR(13), rfc VARCHAR(13),
plan VARCHAR(50) DEFAULT 'basic', plan VARCHAR(50) DEFAULT 'basic',
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
) )
""") """)
# Add subdomain column if table already existed without it
cur.execute("""
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS subdomain VARCHAR(100) UNIQUE
""")
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain)
""")
cur.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS subscriptions ( CREATE TABLE IF NOT EXISTS subscriptions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -88,23 +116,30 @@ def create_template_db():
return True # Created return True # Created
def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner_pin="0000"): def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner_pin="0000", subdomain=None):
"""Create a new tenant: register in master, create DB from template, create owner employee.""" """Create a new tenant: register in master, create DB from template, create owner employee.
If subdomain is not provided, one is auto-generated from the business name.
"""
import bcrypt import bcrypt
ensure_master_tables() ensure_master_tables()
create_template_db() create_template_db()
# Generate subdomain if not provided
if not subdomain:
subdomain = generate_subdomain(name)
# Generate db_name # Generate db_name
conn = get_master_conn() conn = get_master_conn()
cur = conn.cursor() cur = conn.cursor()
# Insert tenant # Insert tenant
cur.execute(""" cur.execute("""
INSERT INTO tenants (name, db_name, rfc) INSERT INTO tenants (name, db_name, rfc, subdomain)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s)
RETURNING id, db_name RETURNING id, db_name
""", (name, f"tenant_{name.lower().replace(' ', '_')[:30]}", rfc)) """, (name, f"tenant_{name.lower().replace(' ', '_')[:30]}", rfc, subdomain))
tenant_id, db_name = cur.fetchone() tenant_id, db_name = cur.fetchone()
# Track schema version # Track schema version
@@ -181,20 +216,20 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
tenant_cur.close() tenant_cur.close()
tenant_conn.close() tenant_conn.close()
return {'tenant_id': tenant_id, 'db_name': db_name, 'owner_id': owner_id} return {'tenant_id': tenant_id, 'db_name': db_name, 'owner_id': owner_id, 'subdomain': subdomain}
def list_tenants(): def list_tenants():
"""List all tenants.""" """List all tenants."""
conn = get_master_conn() conn = get_master_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT id, name, db_name, rfc, plan, is_active, created_at FROM tenants ORDER BY id") cur.execute("SELECT id, name, db_name, rfc, plan, is_active, created_at, subdomain FROM tenants ORDER BY id")
tenants = [] tenants = []
for row in cur.fetchall(): for row in cur.fetchall():
tenants.append({ tenants.append({
'id': row[0], 'name': row[1], 'db_name': row[2], 'id': row[0], 'name': row[1], 'db_name': row[2],
'rfc': row[3], 'plan': row[4], 'is_active': row[5], 'rfc': row[3], 'plan': row[4], 'is_active': row[5],
'created_at': str(row[6]) 'created_at': str(row[6]), 'subdomain': row[7]
}) })
cur.close() cur.close()
conn.close() conn.close()

View File

@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Autoparts — Iniciar Sesión</title> <title>Nexus Autoparts — Iniciar Sesión</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" /> <link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<style> <style>
/* ===================================================================== /* =====================================================================
@@ -1124,10 +1126,23 @@
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
TRIGGER LOGIN (demo) TRIGGER LOGIN (demo)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
// Get tenant_id from URL or localStorage // Tenant resolution: subdomain (server-side) > URL param > localStorage
var tenantId = new URLSearchParams(window.location.search).get('tenant') var _serverTenantId = {{ tenant_id | default('null') | tojson }};
var _serverTenantName = {{ tenant_name | default('null') | tojson }};
var _serverSubdomain = {{ tenant_subdomain | default('null') | tojson }};
var tenantId = _serverTenantId
|| new URLSearchParams(window.location.search).get('tenant')
|| localStorage.getItem('pos_tenant_id') || localStorage.getItem('pos_tenant_id')
|| '11'; // Default tenant — remove in production when multi-tenant selector exists || '11'; // Default tenant — remove in production when multi-tenant selector exists
// Show business name from subdomain if available
if (_serverTenantName) {
var brandNameEl = document.querySelector('.brand-name');
var brandSubEl = document.querySelector('.brand-sub');
if (brandNameEl) brandNameEl.textContent = _serverTenantName;
if (brandSubEl) brandSubEl.textContent = 'Punto de Venta';
}
// Device ID (persistent) // Device ID (persistent)
var deviceId = localStorage.getItem('pos_device_id'); var deviceId = localStorage.getItem('pos_device_id');
if (!deviceId) { if (!deviceId) {
@@ -1277,10 +1292,14 @@
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
function loadEmployees() { function loadEmployees() {
if (!tenantId) { if (!tenantId) {
document.getElementById('usersGrid').innerHTML = '<div style="text-align:center;padding:var(--space-4);color:var(--color-error);">No se especificó tenant. Agrega ?tenant=ID a la URL.</div>'; document.getElementById('usersGrid').innerHTML = '<div style="text-align:center;padding:var(--space-4);color:var(--color-error);">No se especificó tenant. Agrega ?tenant=ID a la URL o usa un subdominio.</div>';
return; return;
} }
fetch('/pos/api/auth/employees/' + tenantId) // If subdomain is set, the server already knows the tenant — use /employees endpoint
var empUrl = _serverSubdomain
? '/pos/api/auth/employees'
: '/pos/api/auth/employees/' + tenantId;
fetch(empUrl)
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
var grid = document.getElementById('usersGrid'); var grid = document.getElementById('usersGrid');
@@ -1342,5 +1361,8 @@
</script> </script>
<script src="/pos/static/js/sync-engine.js"></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body> </body>
</html> </html>