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:
58
nginx/nexus-pos.conf
Normal file
58
nginx/nexus-pos.conf
Normal 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
24
nginx/setup-nginx.sh
Executable 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
|
||||||
61
pos/app.py
61
pos/app.py
@@ -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__':
|
||||||
|
|||||||
@@ -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
116
pos/middleware_tenant.py
Normal 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
|
||||||
7
pos/migrations/v1.2_subdomain.sql
Normal file
7
pos/migrations/v1.2_subdomain.sql
Normal 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';
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user