diff --git a/CLAUDE.md b/CLAUDE.md index 25c2bd6..c32c947 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ A multi-tenant SaaS POS web application for nail salons. The platform is split a | Domain | Audience | Purpose | |---|---|---| -| `admin.mydomain.com` | System Admins | Platform management: system users, tenants, plan enforcement, settings override | -| `mydomain.com` | Tenant users | Salon management: staff, customers, bookings, POS, inventory, marketing, reports | +| `posadmin.ngodanguyen.tech` | System Admins | Platform management: system users, tenants, plan enforcement, settings override | +| `pos.ngodanguyen.tech` | Tenant users | Salon management: staff, customers, bookings, POS, inventory, marketing, reports | A **System Admin** manages the entire platform and can override any tenant's settings. Each **Tenant** (salon owner/manager/staff) operates exclusively within their own salon context, accessed via `mydomain.com/login` with tenant resolved from their credentials. @@ -37,7 +37,7 @@ A **System Admin** manages the entire platform and can override any tenant's set ## Domain Architecture -### Domain 1 — `admin.mydomain.com` (System Admin Portal) +### Domain 1 — `posadmin.ngodanguyen.tech` (System Admin Portal) Served by a dedicated Nginx `server` block. Access is restricted to users with the `superadmin` role. An IP allowlist at the Nginx level provides an additional layer of protection. @@ -49,17 +49,17 @@ Served by a dedicated Nginx `server` block. Access is restricted to users with t - **Audit log** — immutable, append-only log of all superadmin actions (actor, action, target, timestamp, before/after values); 1-year retention - **Platform analytics** — active tenants, MRR, trial conversions, churn rate, revenue trends -### Domain 2 — `mydomain.com` (Tenant Portal) +### Domain 2 — `pos.ngodanguyen.tech` (Tenant Portal) -Served by a separate Nginx `server` block. All tenants share this single domain. The active tenant and location are resolved from the authenticated session. +Served by a separate Nginx `server` block. All tenants share this single domain (`pos.ngodanguyen.tech`). The active tenant and location are resolved from the authenticated session. `mydomain.com` exposes **two distinct login flows**: | URL | Credentials | Who uses it | |---|---|---| -| `mydomain.com/login` | Email + password | `tenant_admin`, `tenant_manager` | -| `mydomain.com/staff-login` | Phone number + passcode (4–6 digit PIN) | `tenant_staff` — kiosk-style login | -| `mydomain.com/checkin/{tenant_slug}` | No credentials — public kiosk page | Customer self check-in on iPad | +| `pos.ngodanguyen.tech/login` | Email + password | `tenant_admin`, `tenant_manager` | +| `pos.ngodanguyen.tech/staff-login` | Phone number + passcode (4–6 digit PIN) | `tenant_staff` — kiosk-style login | +| `pos.ngodanguyen.tech/checkin/{tenant_slug}` | No credentials — public kiosk page | Customer self check-in on iPad | Staff login is designed for quick front-of-house use on a shared tablet. After authenticating, staff land on their personal **Staff Portal** — a simplified view scoped exclusively to their own data. @@ -177,6 +177,11 @@ audit_log before_json, after_json, ip_address, created_at (append-only; no UPDATE or DELETE ever issued against this table) (retention: purge records older than 365 days via monthly APScheduler job) + +jwt_blocklist + id, jti, created_at + (stores revoked JWT refresh token JTIs; persisted to DB — survives Gunicorn worker restarts; + checked on every /api/auth/refresh call; purged with expired tokens via APScheduler) ``` ### Tenant-Level Tables (all carry tenant_id) @@ -639,17 +644,29 @@ WantedBy=multi-user.target ## Development Phases -### Phase 1 — Foundation -- [ ] Project scaffold: two app factories, shared extensions, config classes -- [ ] Shared models: `SystemUser`, `Tenant`, `Plan`, `TenantSettingOverride`, `AuditLog` -- [ ] Tenant-level models: `Location`, `LocationSetting`, `User` -- [ ] `load_tenant_context()` + `load_location_context()` hooks -- [ ] Admin portal auth (login/logout, brute-force lockout, `@require_role`) -- [ ] Tenant portal auth (login/logout, password reset, brute-force lockout) -- [ ] `@demo_readonly` decorator — blocks all write operations on the demo tenant -- [ ] Demo account: `mydomain.com/demo` shortcut, pre-seeded read-only data -- [ ] Database migrations baseline -- [ ] Two Gunicorn entrypoints + two systemd units + Nginx config (with security headers) +### Phase 1 — Foundation ✅ COMPLETE +- [x] Project scaffold: two app factories (`create_admin_app`, `create_tenant_app`), shared `extensions.py`, `config.py` (Dev/Prod/Test) +- [x] Platform models: `SystemUser`, `Tenant`, `Plan`, `TenantBillingHistory`, `TenantSettingOverride`, `AuditLog`, `JWTBlocklist` +- [x] Tenant-level models: all 26 models including `Location`, `User`, `Staff`, `Appointment`, `Transaction`, `CheckinQueue`, `JWTBlocklist`, etc. +- [x] `load_tenant_context()` + `load_location_context()` before-request hooks +- [x] Admin portal auth — login/logout, brute-force lockout (5 attempts → 15 min), password reset with time-limited token +- [x] Tenant portal auth — login/logout, password reset, brute-force lockout, demo login shortcut +- [x] Staff portal auth — phone + passcode login, per-staff brute-force lockout, logout +- [x] `@require_role` decorator — role enforcement on all routes +- [x] `@tenant_feature_required` decorator — plan feature flag gating +- [x] `@demo_readonly` decorator — blocks all write operations on demo tenant +- [x] `app/security.py` — security headers middleware (CSP with CDN allowance), IP allowlist, input sanitisers +- [x] `app/decorators.py`, `app/context.py`, `app/forms.py` — cross-cutting concerns +- [x] Base templates — admin portal (Bootstrap 5, sidebar nav) + tenant portal (Bootstrap 5, sidebar nav, location switcher) +- [x] Auth templates — login, password reset (request + confirm), account locked, staff PIN login, feature unavailable +- [x] Database migrations — circular FK resolved (`appointments` ↔ `transactions` via `use_alter=True`); `db upgrade` verified +- [x] `wsgi_admin.py` + `wsgi_tenant.py` Gunicorn entrypoints +- [x] `deploy/salon_pos_admin.service` + `deploy/salon_pos_tenant.service` systemd units +- [x] `deploy/nginx.conf` — both domains (`posadmin.ngodanguyen.tech`, `pos.ngodanguyen.tech`), TLS, CSP updated for Bootstrap CDN +- [x] `deploy/backup/db_backup.sh` + `backup.cron` — daily 02:00, 30-day retention +- [x] `tests/` — `conftest.py`, `test_admin_auth.py`, `test_tenant_auth.py`, `test_staff_login.py`, `test_tenancy_isolation.py`, `test_security_headers.py` +- [x] `README.md` — full deployment runbook (fresh install, migrations, seeding, service management, backup) +- [ ] Demo account pre-seeded data (deferred — requires Phase 2 tenant creation flow) ### Phase 2 — Admin Portal - [ ] System user management (CRUD, force password reset) @@ -812,11 +829,11 @@ DATABASE_URL=mysql+pymysql://salon_pos_app:password@localhost/salon_pos MAIL_SERVER=smtp.example.com MAIL_PORT=587 MAIL_USE_TLS=true -MAIL_USERNAME=noreply@mydomain.com +MAIL_USERNAME=noreply@ngodanguyen.tech MAIL_PASSWORD= ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24 -ADMIN_DOMAIN=admin.mydomain.com -TENANT_DOMAIN=mydomain.com +ADMIN_DOMAIN=posadmin.ngodanguyen.tech +TENANT_DOMAIN=pos.ngodanguyen.tech DEMO_TENANT_SLUG=demo BACKUP_DIR=/var/backups/salon_pos BACKUP_RETAIN_DAYS=30 @@ -862,6 +879,44 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by ` --- +--- + +## Phase 1 — Implementation Notes + +The following decisions and resolutions were made during Phase 1 scaffold implementation. + +### Real-Time Alert Mechanism +Polling (5-second `setInterval` hitting `GET /api/v1/checkin/queue?status=waiting`) was chosen over SSE and WebSockets for the receptionist dashboard check-in alert. The rationale: the VPS has 4 GB RAM running two Gunicorn processes, MySQL, and Nginx. SSE requires async workers (gevent/eventlet) to avoid blocking Gunicorn worker slots; WebSockets requires Flask-SocketIO plus a message broker (Redis). Polling adds zero infrastructure overhead and the 5-second latency is acceptable for a front-desk use case. + +### Circular Foreign Key Resolution +`appointments.rebooked_from_transaction_id` → `transactions` and `transactions.appointment_id` → `appointments` form a circular FK dependency that prevents MySQL InnoDB from creating either table. Both FKs are declared with `use_alter=True, name="fk_..."` so SQLAlchemy emits them as deferred `ALTER TABLE` statements after all tables are created. This is the correct permanent fix — not a workaround. + +### Dual App Factory — Static File Isolation +Each Flask app factory (`create_admin_app`, `create_tenant_app`) is configured with: +- `static_folder="../static/"` — Flask serves directly from `static/admin/` or `static/tenant/` +- `static_url_path="/static"` — both portals use `/static/css/...` URLs with no portal-name prefix in the path + +This eliminates the double-prefix bug (`/static/admin/admin/css/...`) that occurs when `static_folder` points at the parent `static/` directory. + +### Content Security Policy — CDN Allowance +Bootstrap 5 CSS/JS and Bootstrap Icons are loaded from `cdn.jsdelivr.net`. The CSP must explicitly allow this CDN in `script-src`, `style-src`, and `font-src` directives. This is set in both `app/security.py` (Flask layer) and `deploy/nginx.conf` (Nginx layer, which takes precedence in production). Both must be consistent or Nginx will override Flask's permissive header with the restrictive one. + +### Admin Portal Login URL +The admin auth blueprint uses `url_prefix=""` (not `/admin`). The login page is at `posadmin.ngodanguyen.tech/login`. Visiting `/` redirects to `/login` for unauthenticated users and to `/dashboard` for authenticated ones. + +### JWT Blocklist Table Location +`jwt_blocklist` is defined in `app/models/platform.py` (not `salon.py`) because it is a platform-level concern — shared across all tenants, not scoped to any one salon. Refresh token revocation must survive Gunicorn worker restarts, so in-memory storage is not used. + +### Template Path Resolution +Both portal apps set `template_folder` to the project-root `templates/` directory. All `render_template()` calls and `{% extends %}` directives must use the full path from that root: +- Admin templates: `"admin/auth/login.html"`, `{% extends "admin/layouts/base.html" %}` +- Tenant templates: `"tenant/auth/login.html"`, `{% extends "tenant/layouts/base.html" %}` + +### Phase 2 Blueprint Stubs +All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to `url_for('tenants.index')`, `url_for('system_users.index')` etc. are replaced with `#` until Phase 2 routes are implemented. + +--- + ## Decisions Log | # | Decision | Resolution | @@ -884,4 +939,12 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by ` | 16 | Soft delete | All tenant models carry `deleted_at`. Queries filter `WHERE deleted_at IS NULL`. No hard deletes from application code. Restore available to admin and tenant_admin. | | 17 | Tenant health dashboard | Superadmin portal shows per-tenant signals: last login, appointment volume trend, days to subscription expiry, billing issues. | | 18 | Customer check-in kiosk | Dedicated iPad page at `/checkin/{slug}` (no auth). Customer enters name + phone, optionally selects service. System looks up or creates the customer profile, queues a walk-in entry in `checkin_queue`, and surfaces an alert on the receptionist dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`) — chosen over SSE/WebSockets to minimise resource usage on a constrained VPS. Page auto-resets after 10 seconds. Rate-limited; slug allowlist prevents enumeration. | -| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as `pending` with `rebook_source = checkout`. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. | \ No newline at end of file +| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as `pending` with `rebook_source = checkout`. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. | +| 20 | Real-time alert mechanism | 5-second polling chosen over SSE/WebSockets for receptionist check-in alerts. VPS has 4 GB RAM — SSE needs async Gunicorn workers; WebSockets needs Flask-SocketIO + Redis. Polling adds zero infrastructure overhead. Endpoint: `GET /api/v1/checkin/queue?status=waiting`. | +| 21 | Circular FK resolution | `appointments.rebooked_from_transaction_id` → `transactions` and `transactions.appointment_id` → `appointments` form a cycle. Both FKs use `use_alter=True` so SQLAlchemy defers them as `ALTER TABLE` statements post-creation. | +| 22 | Static file serving | Each app factory points `static_folder` to `static/admin/` or `static/tenant/` directly, with `static_url_path="/static"`. Eliminates double-prefix URL bug (`/static/admin/admin/css/...`). | +| 23 | Content Security Policy | CSP allows `cdn.jsdelivr.net` in `script-src`, `style-src`, and `font-src` for Bootstrap 5 and Bootstrap Icons. Must be set consistently in both `app/security.py` and `deploy/nginx.conf` — Nginx overrides Flask headers in production. | +| 24 | Admin login URL | Admin auth blueprint uses `url_prefix=""`. Login page is at `posadmin.ngodanguyen.tech/login`. Root `/` redirects to `/login` (unauthenticated) or `/dashboard` (authenticated). | +| 25 | JWT blocklist table location | `jwt_blocklist` defined in `platform.py` (not `salon.py`) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts. | +| 26 | Template path convention | `template_folder` points to project-root `templates/`. All `render_template()` calls and `{% extends %}` use full paths: `"admin/auth/login.html"`, `"admin/layouts/base.html"`, `"tenant/auth/login.html"`, etc. | +| 27 | Production domains | Admin portal: `posadmin.ngodanguyen.tech`. Tenant portal: `pos.ngodanguyen.tech`. Updated in `.env`, `nginx.conf`, and all documentation. | \ No newline at end of file diff --git a/README.md b/README.md index 37fb333..12ab3ae 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ A multi-tenant SaaS POS web application for nail salons, served across two dedic | Domain | Audience | Purpose | |---|---|---| -| `admin.mydomain.com` | System Admins | Platform management | -| `mydomain.com` | Tenant users | Salon management | +| `posadmin.ngodanguyen.tech` | System Admins | Platform management | +| `pos.ngodanguyen.tech` | Tenant users | Salon management | --- @@ -93,6 +93,8 @@ flask --app wsgi_tenant:app db upgrade ### 7. Seed initial data (plans + superadmin) +> **Important:** There is no default seeded admin user. You must create one manually using the shell commands below. Choose a strong password — minimum 10 characters, at least one uppercase letter, one lowercase letter, and one digit. + ```bash flask --app wsgi_admin:app shell ``` @@ -160,9 +162,6 @@ sudo chown salonpos:salonpos /var/log/salon_pos_admin /var/log/salon_pos_tenant sudo cp deploy/salon_pos_admin.service /etc/systemd/system/ sudo cp deploy/salon_pos_tenant.service /etc/systemd/system/ sudo systemctl daemon-reload -sudo mkdir -p /run/salon_pos -sudo chown salonpos:salonpos /run/salon_pos -sudo chmod 775 /run/salon_pos sudo systemctl enable salon_pos_admin salon_pos_tenant sudo systemctl start salon_pos_admin salon_pos_tenant sudo systemctl status salon_pos_admin salon_pos_tenant @@ -263,14 +262,38 @@ pip install -r requirements.txt cp .env.example .env # Set FLASK_ENV=development in .env flask --app wsgi_tenant:app db upgrade -flask --app wsgi_tenant:app run --port 5001 -flask --app wsgi_admin:app run --port 5002 +flask --app wsgi_tenant:app run --port 5001 # Tenant portal → http://localhost:5001/login +flask --app wsgi_admin:app run --port 5002 # Admin portal → http://localhost:5002/login ``` --- ## Project Structure +## Phase 1 Resolutions + +The following issues were encountered and resolved during Phase 1 — documented here to inform future contributors. + +### Circular Foreign Keys (MySQL InnoDB) +`appointments.rebooked_from_transaction_id` and `transactions.appointment_id` form a circular FK dependency. MySQL InnoDB enforces FK constraints at `CREATE TABLE` time so neither table could be created first. **Fix:** both columns use `use_alter=True, name="fk_..."` so SQLAlchemy defers them as `ALTER TABLE` statements after all tables are created. + +If you re-generate migrations from scratch, always verify with: +```bash +flask --app wsgi_tenant:app db upgrade +``` +Any `(1824, "Failed to open the referenced table ...")` error means a circular FK is missing `use_alter=True`. + +### Static File Double-Prefix +Flask apps with `static_folder="../static"` and `static_url_path="/static/admin"` produce URLs like `/static/admin/css/admin.css`. If templates then call `url_for('static', filename='admin/css/admin.css')` the URL becomes `/static/admin/admin/css/admin.css` (doubled prefix). **Fix:** each app points `static_folder` directly at its subdirectory (`static/admin/` or `static/tenant/`) with `static_url_path="/static"`. Templates use `url_for('static', filename='css/admin.css')` with no portal prefix in the filename. + +### Content Security Policy and Bootstrap CDN +The CSP must explicitly allow `https://cdn.jsdelivr.net` in `script-src`, `style-src`, and `font-src`. Both `app/security.py` (Flask after-request hook) and `deploy/nginx.conf` must be consistent — Nginx headers override Flask headers in production. + +### Admin Login URL +The admin portal login page is at `posadmin.ngodanguyen.tech/login` (not `/admin/login`). The admin auth blueprint uses `url_prefix=""`. Visiting `/` redirects to `/login`. + +--- + ``` salon_pos/ ├── app/ @@ -283,8 +306,8 @@ salon_pos/ │ ├── models/ │ │ ├── platform.py # SystemUser, Tenant, Plan, AuditLog, ... │ │ └── salon.py # All tenant-scoped models -│ ├── admin/ # admin.mydomain.com blueprints -│ └── tenant/ # mydomain.com blueprints +│ ├── admin/ # posadmin.ngodanguyen.tech blueprints +│ └── tenant/ # pos.ngodanguyen.tech blueprints ├── config.py # Dev / Prod / Test config classes ├── wsgi_admin.py # Gunicorn entrypoint — admin ├── wsgi_tenant.py # Gunicorn entrypoint — tenant @@ -292,4 +315,4 @@ salon_pos/ ├── .env.example ├── deploy/ # systemd units, Nginx config, backup scripts └── tests/ # pytest test suites -``` +``` \ No newline at end of file