319 lines
10 KiB
Markdown
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
|
|
```
|