From 6628f2deef0aa0065d0e36b4296cadf10f6f7ae1 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Thu, 2 Apr 2026 07:16:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20subdomain=20routing=20por=20tenant=20?= =?UTF-8?q?=E2=80=94=20refac-xxx.nexusautoparts.com?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- nginx/nexus-pos.conf | 58 +++++++++++++++ nginx/setup-nginx.sh | 24 +++++++ pos/app.py | 61 +++++++++++++++- pos/blueprints/auth_bp.py | 33 +++++++-- pos/middleware_tenant.py | 116 ++++++++++++++++++++++++++++++ pos/migrations/v1.2_subdomain.sql | 7 ++ pos/services/tenant_manager.py | 51 ++++++++++--- pos/templates/login.html | 30 ++++++-- 8 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 nginx/nexus-pos.conf create mode 100755 nginx/setup-nginx.sh create mode 100644 pos/middleware_tenant.py create mode 100644 pos/migrations/v1.2_subdomain.sql diff --git a/nginx/nexus-pos.conf b/nginx/nexus-pos.conf new file mode 100644 index 0000000..b760f34 --- /dev/null +++ b/nginx/nexus-pos.conf @@ -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 ~^(?.+)\.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; + } +} diff --git a/nginx/setup-nginx.sh b/nginx/setup-nginx.sh new file mode 100755 index 0000000..3104111 --- /dev/null +++ b/nginx/setup-nginx.sh @@ -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 diff --git a/pos/app.py b/pos/app.py index cb474fd..56069d1 100644 --- a/pos/app.py +++ b/pos/app.py @@ -3,6 +3,17 @@ from flask import Flask def create_app(): 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 from blueprints.auth_bp import auth_bp app.register_blueprint(auth_bp) @@ -31,16 +42,22 @@ def create_app(): from blueprints.accounting_bp import accounting_bp app.register_blueprint(accounting_bp) + from blueprints.chat_bp import chat_bp + app.register_blueprint(chat_bp) + # Health check @app.route('/pos/health') def health(): 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') 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') def pos_catalog(): @@ -82,6 +99,46 @@ def create_app(): def pos_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 if __name__ == '__main__': diff --git a/pos/blueprints/auth_bp.py b/pos/blueprints/auth_bp.py index 1b8befc..d36d130 100644 --- a/pos/blueprints/auth_bp.py +++ b/pos/blueprints/auth_bp.py @@ -5,7 +5,7 @@ import jwt import bcrypt import time 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 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']) 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 {} - 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', '') device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown')) # Optional: branch_id from the device for PIN search optimization @@ -158,10 +165,24 @@ def login_pin(): @auth_bp.route('/employees/', methods=['GET']) -def list_login_employees(tenant_id): - """Public endpoint: list employees for the login screen (names + roles only, no sensitive data).""" +@auth_bp.route('/employees', methods=['GET']) +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: - conn = get_tenant_conn(tenant_id) + conn = get_tenant_conn(tid) except ValueError: return jsonify({'error': 'Tenant not found'}), 404 diff --git a/pos/middleware_tenant.py b/pos/middleware_tenant.py new file mode 100644 index 0000000..789d1b4 --- /dev/null +++ b/pos/middleware_tenant.py @@ -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: .nexusautoparts.com or .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 diff --git a/pos/migrations/v1.2_subdomain.sql b/pos/migrations/v1.2_subdomain.sql new file mode 100644 index 0000000..c302649 --- /dev/null +++ b/pos/migrations/v1.2_subdomain.sql @@ -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'; diff --git a/pos/services/tenant_manager.py b/pos/services/tenant_manager.py index 0b7fd2a..bc757d8 100644 --- a/pos/services/tenant_manager.py +++ b/pos/services/tenant_manager.py @@ -2,6 +2,8 @@ """Create and manage tenant databases.""" import os +import re +import unicodedata import psycopg2 from psycopg2 import sql 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') +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(): """Create tenants/subscriptions/schema_version tables in nexus_master if missing.""" conn = get_master_conn() @@ -21,12 +41,20 @@ def ensure_master_tables(): id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, db_name VARCHAR(100) UNIQUE NOT NULL, + subdomain VARCHAR(100) UNIQUE, rfc VARCHAR(13), plan VARCHAR(50) DEFAULT 'basic', is_active BOOLEAN DEFAULT TRUE, 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(""" CREATE TABLE IF NOT EXISTS subscriptions ( id SERIAL PRIMARY KEY, @@ -88,23 +116,30 @@ def create_template_db(): return True # Created -def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner_pin="0000"): - """Create a new tenant: register in master, create DB from template, create owner employee.""" +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. + + If subdomain is not provided, one is auto-generated from the business name. + """ import bcrypt ensure_master_tables() create_template_db() + # Generate subdomain if not provided + if not subdomain: + subdomain = generate_subdomain(name) + # Generate db_name conn = get_master_conn() cur = conn.cursor() # Insert tenant cur.execute(""" - INSERT INTO tenants (name, db_name, rfc) - VALUES (%s, %s, %s) + INSERT INTO tenants (name, db_name, rfc, subdomain) + VALUES (%s, %s, %s, %s) 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() # Track schema version @@ -181,20 +216,20 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner tenant_cur.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(): """List all tenants.""" conn = get_master_conn() 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 = [] for row in cur.fetchall(): tenants.append({ 'id': row[0], 'name': row[1], 'db_name': row[2], '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() conn.close() diff --git a/pos/templates/login.html b/pos/templates/login.html index 41bef01..929cc52 100644 --- a/pos/templates/login.html +++ b/pos/templates/login.html @@ -6,6 +6,8 @@ Nexus Autoparts — Iniciar Sesión + +