Files
2026-05-07 12:17:19 -04:00

319 lines
10 KiB
Markdown

# Salon POS — Multi-Tenant SaaS
## Overview
A multi-tenant SaaS POS web application for nail salons, served across two dedicated domains:
| Domain | Audience | Purpose |
|---|---|---|
| `posadmin.ngodanguyen.tech` | System Admins | Platform management |
| `pos.ngodanguyen.tech` | Tenant users | Salon management |
---
## Tech Stack
- **Backend:** Python 3.11+ / Flask
- **Database:** MySQL 8.0+ with SQLAlchemy ORM
- **Auth:** Flask-Login (UI) + Flask-JWT-Extended (API)
- **Frontend:** Jinja2 + Bootstrap 5 + Vanilla JS
- **WSGI:** Gunicorn (2 independent processes)
- **Reverse Proxy:** Nginx
- **Scheduler:** APScheduler
- **Process Manager:** systemd
---
## Fresh Install (Production)
### 1. System dependencies
```bash
sudo apt update && sudo apt install -y python3.11 python3.11-venv python3-pip \
mysql-server nginx libmysqlclient-dev build-essential libssl-dev
```
### 2. Application user
```bash
sudo useradd -m -s /bin/bash salonpos
sudo mkdir -p /opt/salon_pos
sudo chown salonpos:salonpos /opt/salon_pos
```
### 3. Clone and set up virtual environment
```bash
cd /opt/salon_pos
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
### 4. Environment configuration
```bash
cp .env.example .env
chmod 600 .env
# Edit .env with your values — SECRET_KEY, ADMIN_SECRET_KEY, DATABASE_URL, MAIL_*, etc.
nano .env
```
### 5. MySQL setup
```sql
CREATE DATABASE salon_pos CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'salon_pos_app'@'localhost' IDENTIFIED BY 'strong-password-here';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP
ON salon_pos.* TO 'salon_pos_app'@'localhost';
-- Backup user (read-only)
CREATE USER 'salon_pos_backup'@'localhost' IDENTIFIED BY 'backup-password-here';
GRANT SELECT, LOCK TABLES ON salon_pos.* TO 'salon_pos_backup'@'localhost';
FLUSH PRIVILEGES;
```
Store backup credentials in `/etc/mysql/backup.cnf` (mode 600):
```ini
[client]
user=salon_pos_backup
password=backup-password-here
host=localhost
```
### 6. Database migrations
```bash
cd /opt/salon_pos
source venv/bin/activate
flask --app wsgi_tenant:app db init # First time only
flask --app wsgi_tenant:app db migrate -m "Phase 1 — initial schema"
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
```
```python
from app.extensions import db, bcrypt
from app.models.platform import Plan, SystemUser
# Create subscription plans
plans = [
Plan(name="Starter", price_monthly=29.00, max_staff=3, max_locations=1,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": False, "commission": False,
"full_reports": False, "multi_location": False,
"online_booking": False, "waitlist": False,
"marketing": False}),
Plan(name="Growth", price_monthly=59.00, max_staff=10, max_locations=3,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": True, "commission": True,
"full_reports": True, "multi_location": True,
"online_booking": True, "waitlist": True,
"marketing": False}),
Plan(name="Pro", price_monthly=99.00, max_staff=None, max_locations=None,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": True, "commission": True,
"full_reports": True, "multi_location": True,
"online_booking": True, "waitlist": True,
"marketing": True}),
]
db.session.add_all(plans)
# Create first superadmin
admin = SystemUser(
email="admin@yourdomain.com",
password_hash=bcrypt.generate_password_hash("ChangeMe1!").decode("utf-8"),
name="System Admin",
role="superadmin",
is_active=True,
)
db.session.add(admin)
db.session.commit()
print("Seeded successfully.")
exit()
```
### 8. Log directories
```bash
sudo mkdir -p /var/log/salon_pos_admin /var/log/salon_pos_tenant
sudo chown salonpos:salonpos /var/log/salon_pos_admin /var/log/salon_pos_tenant
```
### 9. systemd services
```bash
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 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
```
### 10. Nginx
```bash
sudo cp deploy/nginx.conf /etc/nginx/sites-available/salon_pos
sudo ln -s /etc/nginx/sites-available/salon_pos /etc/nginx/sites-enabled/
# Update IP allowlist in nginx.conf before enabling
sudo nginx -t
sudo systemctl reload nginx
```
### 11. Backup cron
```bash
sudo cp deploy/backup/db_backup.sh /opt/salon_pos/deploy/backup/
sudo chmod +x /opt/salon_pos/deploy/backup/db_backup.sh
sudo mkdir -p /var/backups/salon_pos
sudo chown salonpos:salonpos /var/backups/salon_pos
# Install cron job for salonpos user
sudo crontab -u salonpos deploy/backup/backup.cron
```
---
## Service Management
```bash
# Status
sudo systemctl status salon_pos_admin
sudo systemctl status salon_pos_tenant
# Restart
sudo systemctl restart salon_pos_admin
sudo systemctl restart salon_pos_tenant
# View logs
sudo journalctl -u salon_pos_admin -f
sudo journalctl -u salon_pos_tenant -f
tail -f /var/log/salon_pos_admin/error.log
tail -f /var/log/salon_pos_tenant/error.log
```
---
## Database Migrations (Ongoing)
```bash
cd /opt/salon_pos
source venv/bin/activate
flask --app wsgi_tenant:app db migrate -m "Description of change"
flask --app wsgi_tenant:app db upgrade
# Rollback one step
flask --app wsgi_tenant:app db downgrade
```
---
## Running Tests
```bash
cd /opt/salon_pos
source venv/bin/activate
pip install pytest
FLASK_ENV=testing pytest tests/ -v
```
---
## Monthly Restore Drill (Mandatory)
```bash
# On staging server:
LATEST=$(ls -t /var/backups/salon_pos/*.sql.gz | head -1)
gunzip -c "$LATEST" | mysql -u root salon_pos_staging
echo "Restore drill completed: $LATEST"
# Document the result and date in this runbook.
```
**Last restore drill:** _______________
**Performed by:** _______________
**Result:** _______________
---
## Development Setup
```bash
git clone <repo>
cd salon_pos
python3.11 -m venv venv
source venv/bin/activate
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 # 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/
│ ├── __init__.py
│ ├── extensions.py # Shared Flask extensions
│ ├── context.py # load_tenant_context, load_location_context
│ ├── decorators.py # @require_role, @tenant_feature_required, @demo_readonly
│ ├── security.py # Security headers + input sanitisers
│ ├── forms.py # Password validation helper
│ ├── models/
│ │ ├── platform.py # SystemUser, Tenant, Plan, AuditLog, ...
│ │ └── salon.py # All tenant-scoped models
│ ├── 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
├── requirements.txt
├── .env.example
├── deploy/ # systemd units, Nginx config, backup scripts
└── tests/ # pytest test suites
```