05/06 Updated markdown files

This commit is contained in:
2026-05-06 17:24:02 -04:00
parent 04aae8bf5d
commit d924e41d9e
2 changed files with 119 additions and 33 deletions
+85 -22
View File
@@ -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 (46 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 (46 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/<portal>"` — 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 |
@@ -885,3 +940,11 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by `
| 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. |
| 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. |
+32 -9
View File
@@ -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