From dde18a2cd2c2a09e47b61183619e28a9ddd8881a Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 6 May 2026 14:19:07 -0400 Subject: [PATCH] 05/06/2026 Initial commit --- .env.example | 23 + CLAUDE.md | 10 +- README.md | 293 ++++++- app/__init__.py | 2 + app/admin/__init__.py | 83 ++ app/admin/analytics/__init__.py | 0 app/admin/analytics/routes.py | 7 + app/admin/audit_log/__init__.py | 0 app/admin/audit_log/routes.py | 7 + app/admin/auth/__init__.py | 0 app/admin/auth/routes.py | 107 +++ app/admin/billing/__init__.py | 0 app/admin/billing/routes.py | 7 + app/admin/plans/__init__.py | 0 app/admin/plans/routes.py | 7 + app/admin/settings_override/__init__.py | 0 app/admin/settings_override/routes.py | 7 + app/admin/system_users/__init__.py | 0 app/admin/system_users/routes.py | 7 + app/admin/tenants/__init__.py | 0 app/admin/tenants/routes.py | 7 + app/api/__init__.py | 0 app/api/auth/__init__.py | 0 app/api/v1/__init__.py | 0 app/context.py | 126 +++ app/decorators.py | 126 +++ app/extensions.py | 36 + app/forms.py | 39 + app/models/__init__.py | 44 ++ app/models/platform.py | 275 +++++++ app/models/salon.py | 744 ++++++++++++++++++ app/security.py | 99 +++ app/static/admin/css/main.css | 2 + app/static/tenant/css/main.css | 2 + app/templates/admin/auth/login.html | 28 + app/templates/admin/base.html | 36 + app/templates/tenant/auth/cancelled.html | 10 + app/templates/tenant/auth/login.html | 34 + .../tenant/auth/password_reset_confirm.html | 29 + .../tenant/auth/password_reset_request.html | 28 + app/templates/tenant/auth/suspended.html | 11 + app/templates/tenant/base.html | 40 + app/templates/tenant/checkin/kiosk.html | 67 ++ app/templates/tenant/dashboard/index.html | 10 + app/templates/tenant/staff_auth/login.html | 33 + app/tenant/__init__.py | 105 +++ app/tenant/appointments/__init__.py | 0 app/tenant/appointments/routes.py | 7 + app/tenant/auth/__init__.py | 0 app/tenant/auth/routes.py | 198 +++++ app/tenant/booking/__init__.py | 0 app/tenant/booking/routes.py | 7 + app/tenant/checkin/__init__.py | 0 app/tenant/checkin/routes.py | 135 ++++ app/tenant/customers/__init__.py | 0 app/tenant/customers/routes.py | 7 + app/tenant/dashboard/__init__.py | 0 app/tenant/dashboard/routes.py | 23 + app/tenant/gift_cards/__init__.py | 0 app/tenant/gift_cards/routes.py | 7 + app/tenant/inventory/__init__.py | 0 app/tenant/inventory/routes.py | 7 + app/tenant/locations/__init__.py | 0 app/tenant/locations/routes.py | 7 + app/tenant/marketing/__init__.py | 0 app/tenant/marketing/routes.py | 7 + app/tenant/pos/__init__.py | 0 app/tenant/pos/routes.py | 7 + app/tenant/reconciliation/__init__.py | 0 app/tenant/reconciliation/routes.py | 7 + app/tenant/reports/__init__.py | 0 app/tenant/reports/routes.py | 7 + app/tenant/reviews/__init__.py | 0 app/tenant/reviews/routes.py | 7 + app/tenant/services/__init__.py | 0 app/tenant/services/routes.py | 7 + app/tenant/settings/__init__.py | 0 app/tenant/settings/routes.py | 7 + app/tenant/staff/__init__.py | 0 app/tenant/staff/routes.py | 7 + app/tenant/staff_auth/__init__.py | 0 app/tenant/staff_auth/routes.py | 113 +++ app/tenant/staff_portal/__init__.py | 0 app/tenant/staff_portal/routes.py | 7 + app/tenant/waitlist/__init__.py | 0 app/tenant/waitlist/routes.py | 7 + config.py | 98 +++ deploy/backup/backup.cron | 1 + deploy/backup/db_backup.sh | 13 + deploy/nginx.conf | 69 ++ deploy/salon_pos_admin.service | 18 + deploy/salon_pos_tenant.service | 18 + requirements.txt | 19 + static/admin/css/admin.css | 2 + static/tenant/css/tenant.css | 2 + templates/admin/auth/login.html | 47 ++ .../admin/auth/password_reset_confirm.html | 34 + .../admin/auth/password_reset_request.html | 28 + templates/admin/layouts/base.html | 96 +++ templates/tenant/auth/account_locked.html | 17 + templates/tenant/auth/login.html | 54 ++ .../tenant/auth/password_reset_confirm.html | 34 + .../tenant/auth/password_reset_request.html | 28 + templates/tenant/feature_unavailable.html | 18 + templates/tenant/layouts/base.html | 189 +++++ templates/tenant/staff_auth/staff_login.html | 51 ++ tests/__init__.py | 0 tests/conftest.py | 76 ++ tests/test_admin_auth.py | 17 + tests/test_demo_readonly.py | 50 ++ tests/test_security_headers.py | 25 + tests/test_staff_login.py | 93 +++ tests/test_tenancy_isolation.py | 81 ++ tests/test_tenant_auth.py | 27 + wsgi_admin.py | 3 + wsgi_tenant.py | 3 + 116 files changed, 4276 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 app/__init__.py create mode 100644 app/admin/__init__.py create mode 100644 app/admin/analytics/__init__.py create mode 100644 app/admin/analytics/routes.py create mode 100644 app/admin/audit_log/__init__.py create mode 100644 app/admin/audit_log/routes.py create mode 100644 app/admin/auth/__init__.py create mode 100644 app/admin/auth/routes.py create mode 100644 app/admin/billing/__init__.py create mode 100644 app/admin/billing/routes.py create mode 100644 app/admin/plans/__init__.py create mode 100644 app/admin/plans/routes.py create mode 100644 app/admin/settings_override/__init__.py create mode 100644 app/admin/settings_override/routes.py create mode 100644 app/admin/system_users/__init__.py create mode 100644 app/admin/system_users/routes.py create mode 100644 app/admin/tenants/__init__.py create mode 100644 app/admin/tenants/routes.py create mode 100644 app/api/__init__.py create mode 100644 app/api/auth/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/context.py create mode 100644 app/decorators.py create mode 100644 app/extensions.py create mode 100644 app/forms.py create mode 100644 app/models/__init__.py create mode 100644 app/models/platform.py create mode 100644 app/models/salon.py create mode 100644 app/security.py create mode 100644 app/static/admin/css/main.css create mode 100644 app/static/tenant/css/main.css create mode 100644 app/templates/admin/auth/login.html create mode 100644 app/templates/admin/base.html create mode 100644 app/templates/tenant/auth/cancelled.html create mode 100644 app/templates/tenant/auth/login.html create mode 100644 app/templates/tenant/auth/password_reset_confirm.html create mode 100644 app/templates/tenant/auth/password_reset_request.html create mode 100644 app/templates/tenant/auth/suspended.html create mode 100644 app/templates/tenant/base.html create mode 100644 app/templates/tenant/checkin/kiosk.html create mode 100644 app/templates/tenant/dashboard/index.html create mode 100644 app/templates/tenant/staff_auth/login.html create mode 100644 app/tenant/__init__.py create mode 100644 app/tenant/appointments/__init__.py create mode 100644 app/tenant/appointments/routes.py create mode 100644 app/tenant/auth/__init__.py create mode 100644 app/tenant/auth/routes.py create mode 100644 app/tenant/booking/__init__.py create mode 100644 app/tenant/booking/routes.py create mode 100644 app/tenant/checkin/__init__.py create mode 100644 app/tenant/checkin/routes.py create mode 100644 app/tenant/customers/__init__.py create mode 100644 app/tenant/customers/routes.py create mode 100644 app/tenant/dashboard/__init__.py create mode 100644 app/tenant/dashboard/routes.py create mode 100644 app/tenant/gift_cards/__init__.py create mode 100644 app/tenant/gift_cards/routes.py create mode 100644 app/tenant/inventory/__init__.py create mode 100644 app/tenant/inventory/routes.py create mode 100644 app/tenant/locations/__init__.py create mode 100644 app/tenant/locations/routes.py create mode 100644 app/tenant/marketing/__init__.py create mode 100644 app/tenant/marketing/routes.py create mode 100644 app/tenant/pos/__init__.py create mode 100644 app/tenant/pos/routes.py create mode 100644 app/tenant/reconciliation/__init__.py create mode 100644 app/tenant/reconciliation/routes.py create mode 100644 app/tenant/reports/__init__.py create mode 100644 app/tenant/reports/routes.py create mode 100644 app/tenant/reviews/__init__.py create mode 100644 app/tenant/reviews/routes.py create mode 100644 app/tenant/services/__init__.py create mode 100644 app/tenant/services/routes.py create mode 100644 app/tenant/settings/__init__.py create mode 100644 app/tenant/settings/routes.py create mode 100644 app/tenant/staff/__init__.py create mode 100644 app/tenant/staff/routes.py create mode 100644 app/tenant/staff_auth/__init__.py create mode 100644 app/tenant/staff_auth/routes.py create mode 100644 app/tenant/staff_portal/__init__.py create mode 100644 app/tenant/staff_portal/routes.py create mode 100644 app/tenant/waitlist/__init__.py create mode 100644 app/tenant/waitlist/routes.py create mode 100644 config.py create mode 100644 deploy/backup/backup.cron create mode 100644 deploy/backup/db_backup.sh create mode 100644 deploy/nginx.conf create mode 100644 deploy/salon_pos_admin.service create mode 100644 deploy/salon_pos_tenant.service create mode 100644 requirements.txt create mode 100644 static/admin/css/admin.css create mode 100644 static/tenant/css/tenant.css create mode 100644 templates/admin/auth/login.html create mode 100644 templates/admin/auth/password_reset_confirm.html create mode 100644 templates/admin/auth/password_reset_request.html create mode 100644 templates/admin/layouts/base.html create mode 100644 templates/tenant/auth/account_locked.html create mode 100644 templates/tenant/auth/login.html create mode 100644 templates/tenant/auth/password_reset_confirm.html create mode 100644 templates/tenant/auth/password_reset_request.html create mode 100644 templates/tenant/feature_unavailable.html create mode 100644 templates/tenant/layouts/base.html create mode 100644 templates/tenant/staff_auth/staff_login.html create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_admin_auth.py create mode 100644 tests/test_demo_readonly.py create mode 100644 tests/test_security_headers.py create mode 100644 tests/test_staff_login.py create mode 100644 tests/test_tenancy_isolation.py create mode 100644 tests/test_tenant_auth.py create mode 100644 wsgi_admin.py create mode 100644 wsgi_tenant.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4b24b78 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +FLASK_ENV=production +SECRET_KEY=change-me-to-a-random-256-bit-key +ADMIN_SECRET_KEY=separate-key-for-admin-portal-sessions +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_PASSWORD= +ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24 +ADMIN_DOMAIN=admin.mydomain.com +TENANT_DOMAIN=mydomain.com +DEMO_TENANT_SLUG=demo +BACKUP_DIR=/var/backups/salon_pos +BACKUP_RETAIN_DAYS=30 +SESSION_TIMEOUT_TENANT=1800 +SESSION_TIMEOUT_ADMIN=3600 +JWT_ACCESS_TOKEN_EXPIRES=900 +JWT_REFRESH_TOKEN_EXPIRES=604800 +MAX_LOGIN_ATTEMPTS=5 +LOGIN_LOCKOUT_MINUTES=15 +STAFF_PASSCODE_MIN_LENGTH=4 +STAFF_PASSCODE_MAX_LENGTH=6 diff --git a/CLAUDE.md b/CLAUDE.md index 708d0d0..25c2bd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ The **Customer Check-In Kiosk** (`mydomain.com/checkin/{tenant_slug}`) is a sepa - No access to other staff’s data, full customer PII, reports, inventory, or any settings - **Customers** — tenant-wide profiles; visit history (location-aware); notes; loyalty points; birthday and preferred-staff tracking; customer search and merge - **Services & products** — tenant-wide service menu (name, category, duration, price); product catalogue (SKU, category, sale price); enable/disable per location; promotions management (percentage-off discount on any service or product, with defined start/end dates; multiple active promotions supported simultaneously) -- **Customer Check-In Kiosk** — public iPad page at `mydomain.com/checkin/{tenant_slug}`; no login required; customer enters their name and phone number and optionally selects the service they are here for; system looks up the customer profile by phone (new profile created automatically if not found); a walk-in entry is queued and a real-time alert is pushed to the receptionist’s dashboard; the iPad screen shows a confirmation message and resets automatically after 10 seconds for the next customer; rate-limited and CSRF-exempt (no session); tenant slug validated against allowlist to prevent enumeration +- **Customer Check-In Kiosk** — public iPad page at `mydomain.com/checkin/{tenant_slug}`; no login required; customer enters their name and phone number and optionally selects the service they are here for; system looks up the customer profile by phone (new profile created automatically if not found); a walk-in entry is queued and an alert is surfaced on the receptionist’s dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`); the iPad screen shows a confirmation message and resets automatically after 10 seconds for the next customer; rate-limited and CSRF-exempt (no session); tenant slug validated against allowlist to prevent enumeration - **Bookings / Appointments** — calendar view per location; walk-in and advance bookings; status workflow (pending → confirmed → in-progress → completed → cancelled); cancellation reason capture; appointment notes - **Online Customer Booking** — public booking page at `mydomain.com/book/{tenant_slug}` (no login required); customer selects service → preferred staff (optional) → available time slot → submits name + phone; appointment lands in calendar as `pending`; owner can configure auto-confirm; confirmation email sent to customer; available on Growth and Pro plans - **Waitlist** — when a time slot is full, customers can join a waitlist for that slot or staff member; on cancellation the next waitlist entry is notified by email automatically; waitlist queue visible in the appointment calendar; SMS notification deferred to future phase @@ -671,7 +671,7 @@ WantedBy=multi-user.target - [ ] Promotions management (create/edit/deactivate promotions; percentage-off; date range; target specific or all services/products) - [ ] Promotion engine: `get_active_promotion(item_id, item_type)` helper — called at checkout to resolve applicable promotion for each line item automatically - [ ] Appointments (calendar, walk-in flag, status workflow, cancellation + no-show capture; no-show count incremented on customer record) -- [ ] Customer check-in kiosk (`/checkin/{slug}`; no auth; phone lookup or new profile; walk-in queued to `checkin_queue`; real-time dashboard alert to receptionist; auto-reset timer; rate limiting; `service_requested` selector) +- [ ] Customer check-in kiosk (`/checkin/{slug}`; no auth; phone lookup or new profile; walk-in queued to `checkin_queue`; receptionist alert via 5-second polling (`GET /api/v1/checkin/queue?status=waiting`); auto-reset timer; rate limiting; `service_requested` selector) - [ ] Online customer booking (`/book/{slug}`; no auth; availability check; confirmation email; auto-confirm toggle) - [ ] Waitlist (join when slot full; auto-notify on cancellation; queue management in calendar) - [ ] Staff Portal — `/staff/portal` (personal schedule, upcoming appointments, clock-in/clock-out, commission summary, payment history, read-only profile) @@ -764,7 +764,7 @@ WantedBy=multi-user.target | Location isolation | `g.location` validated against `staff_locations` for `tenant_staff` role; 403 on violation | | Feature gating | `@tenant_feature_required('flag')` decorator; flags from `plans.features_json` | | Staff portal isolation | `tenant_staff` sessions routed exclusively to `/staff/portal/*`; `@require_role` blocks all owner/manager routes; staff cannot access other staff members' data | -| Kiosk endpoint security | `/checkin/{slug}` is CSRF-exempt (no session); tenant slug validated against known slugs (not guessable); rate-limited at 20 req/min per IP (Flask-Limiter); only `name`, `phone`, and `service_requested` accepted — all other fields ignored; phone number sanitised and validated before profile lookup | +| Kiosk endpoint security | `/checkin/{slug}` is CSRF-exempt (no session); tenant slug validated against known slugs (not guessable); rate-limited at 20 req/min per IP (Flask-Limiter); only `name`, `phone`, and `service_requested` accepted — all other fields ignored; phone number sanitised and validated before profile lookup; receptionist dashboard alert delivered via 5-second JS polling (`GET /api/v1/checkin/queue?status=waiting`) — no WebSockets or SSE required | | Demo read-only | `@demo_readonly` decorator returns 403 on any write operation for demo tenant | | Superadmin IP gate | Nginx `allow`/`deny` + Flask checks `ADMIN_IP_ALLOWLIST` for double enforcement | @@ -883,5 +883,5 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by ` | 15 | End-of-day reconciliation | Close Day action; cash + app + tip totals vs actual cash counted; variance flagged; stored in `daily_reconciliations`. | | 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 pushes a real-time alert to the receptionist dashboard. 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. | +| 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 diff --git a/README.md b/README.md index 182f5cc..111f3dd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,292 @@ -# MyPOS +# Salon POS — Multi-Tenant SaaS -POS system for Nails Salon \ No newline at end of file +## Overview + +A multi-tenant SaaS POS web application for nail salons, served across two dedicated domains: + +| Domain | Audience | Purpose | +|---|---|---| +| `admin.mydomain.com` | System Admins | Platform management | +| `mydomain.com` | 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) + +```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 +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 +flask --app wsgi_admin:app run --port 5002 +``` + +--- + +## Project Structure + +``` +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/ # admin.mydomain.com blueprints +│ └── tenant/ # mydomain.com 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 +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..935fca5 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +# Shared package marker — do not instantiate apps here. +# Use create_admin_app() or create_tenant_app() instead. diff --git a/app/admin/__init__.py b/app/admin/__init__.py new file mode 100644 index 0000000..5942c08 --- /dev/null +++ b/app/admin/__init__.py @@ -0,0 +1,83 @@ +""" +app/admin/__init__.py — Admin app factory (admin.mydomain.com). +""" + +import logging +from datetime import timedelta +from flask import Flask +from app.extensions import db, migrate, csrf, mail, scheduler, admin_login_manager, admin_jwt, limiter +from app.security import apply_security_headers, check_admin_ip +from config import get_config + +logger = logging.getLogger(__name__) + + +def create_admin_app(config_override=None): + flask_app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", + static_url_path="/static/admin", + ) + + # ── Config ──────────────────────────────────────────────── + cfg = config_override or get_config() + flask_app.config.from_object(cfg) + + # Admin portal uses a separate secret key and session cookie name + import os + flask_app.config["SECRET_KEY"] = os.environ.get( + "ADMIN_SECRET_KEY", flask_app.config["SECRET_KEY"] + ) + flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_admin_session" + flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta( + seconds=int(os.environ.get("SESSION_TIMEOUT_ADMIN", 3600)) + ) + flask_app.config["SESSION_COOKIE_HTTPONLY"] = True + flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + + # ── Extensions ──────────────────────────────────────────── + db.init_app(flask_app) + migrate.init_app(flask_app, db) + csrf.init_app(flask_app) + mail.init_app(flask_app) + limiter.init_app(flask_app) + + # Admin login manager + admin_login_manager.login_view = "admin_auth.login" + admin_login_manager.login_message_category = "warning" + admin_login_manager.session_protection = "strong" + admin_login_manager.init_app(flask_app) + + # Admin JWT + admin_jwt.init_app(flask_app) + + # ── User loader ─────────────────────────────────────────── + from app.models.platform import SystemUser + + @admin_login_manager.user_loader + def load_admin_user(user_id: str): + if not user_id.startswith("system:"): + return None + try: + uid = int(user_id.split(":")[1]) + except (ValueError, IndexError): + return None + return SystemUser.query.get(uid) + + # ── Security ────────────────────────────────────────────── + apply_security_headers(flask_app) + check_admin_ip(flask_app) + + # ── Blueprints ──────────────────────────────────────────── + from app.admin.auth.routes import admin_auth_bp + flask_app.register_blueprint(admin_auth_bp) + + # Placeholder blueprints registered in later phases: + # system_users, tenants, plans, billing, settings_override, audit_log, analytics + + # ── Import all models for Migrate ───────────────────────── + import app.models # noqa: F401 + + logger.info("Admin app created (env=%s)", flask_app.config.get("FLASK_ENV")) + return flask_app diff --git a/app/admin/analytics/__init__.py b/app/admin/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/analytics/routes.py b/app/admin/analytics/routes.py new file mode 100644 index 0000000..4312969 --- /dev/null +++ b/app/admin/analytics/routes.py @@ -0,0 +1,7 @@ +""" +app/admin/analytics/routes.py +Phase 2 implementation. +""" +from flask import Blueprint + +analytics_bp = Blueprint("analytics", __name__, url_prefix="/analytics") diff --git a/app/admin/audit_log/__init__.py b/app/admin/audit_log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/audit_log/routes.py b/app/admin/audit_log/routes.py new file mode 100644 index 0000000..9d0638b --- /dev/null +++ b/app/admin/audit_log/routes.py @@ -0,0 +1,7 @@ +""" +app/admin/audit_log/routes.py +Phase 2 implementation. +""" +from flask import Blueprint + +audit_log_bp = Blueprint("audit_log", __name__, url_prefix="/audit-log") diff --git a/app/admin/auth/__init__.py b/app/admin/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/auth/routes.py b/app/admin/auth/routes.py new file mode 100644 index 0000000..8f567d3 --- /dev/null +++ b/app/admin/auth/routes.py @@ -0,0 +1,107 @@ +""" +app/admin/auth/routes.py — Admin portal authentication. +Routes: /admin/login, /admin/logout +Brute-force lockout: 5 failures → locked for LOGIN_LOCKOUT_MINUTES. +""" + +import logging +from datetime import datetime, timezone, timedelta +from flask import ( + Blueprint, render_template, redirect, url_for, + flash, request, current_app, session, +) +from flask_login import login_user, logout_user, login_required, current_user +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +import bcrypt + +from app.extensions import db, limiter +from app.models.platform import SystemUser, AuditLog + +logger = logging.getLogger(__name__) + +admin_auth_bp = Blueprint("admin_auth", __name__, url_prefix="/admin") + + +@admin_auth_bp.route("/login", methods=["GET", "POST"]) +@limiter.limit("10 per minute") +def login(): + if current_user.is_authenticated: + return redirect(url_for("admin_auth.dashboard_redirect")) + + error = None + + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + + user = SystemUser.query.filter_by(email=email, is_active=True).first() + + max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5) + lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) + + if user and user.is_locked(): + logger.warning("Admin login blocked — account locked: %s", email) + error = f"Account locked. Try again in {lockout_minutes} minutes." + elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()): + # Success — reset lockout counters + user.failed_login_attempts = 0 + user.locked_until = None + user.last_login_at = datetime.now(timezone.utc) + db.session.commit() + + login_user(user, remember=False) + session.permanent = True + + AuditLog.log( + actor_id=user.id, + actor_type="system_user", + action="auth.login", + ip_address=request.remote_addr, + ) + db.session.commit() + + logger.info("Admin login success: %s", email) + next_url = request.args.get("next") or url_for("admin_auth.dashboard_redirect") + return redirect(next_url) + else: + # Failed attempt + if user: + user.failed_login_attempts += 1 + if user.failed_login_attempts >= max_attempts: + user.locked_until = datetime.now(timezone.utc) + timedelta( + minutes=lockout_minutes + ) + logger.warning( + "Admin account locked after %d failures: %s", + max_attempts, email, + ) + db.session.commit() + + logger.warning("Admin login failed: %s", email) + error = "Invalid email or password." + + return render_template("admin/auth/login.html", error=error) + + +@admin_auth_bp.route("/logout") +@login_required +def logout(): + AuditLog.log( + actor_id=current_user.id, + actor_type="system_user", + action="auth.logout", + ip_address=request.remote_addr, + ) + db.session.commit() + logout_user() + session.clear() + logger.info("Admin logout") + return redirect(url_for("admin_auth.login")) + + +@admin_auth_bp.route("/") +@login_required +def dashboard_redirect(): + # Will route to the analytics dashboard in Phase 2 + return redirect(url_for("admin_auth.login")) diff --git a/app/admin/billing/__init__.py b/app/admin/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/billing/routes.py b/app/admin/billing/routes.py new file mode 100644 index 0000000..e1a6174 --- /dev/null +++ b/app/admin/billing/routes.py @@ -0,0 +1,7 @@ +""" +app/admin/billing/routes.py +Phase 2 implementation. +""" +from flask import Blueprint + +billing_bp = Blueprint("billing", __name__, url_prefix="/billing") diff --git a/app/admin/plans/__init__.py b/app/admin/plans/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/plans/routes.py b/app/admin/plans/routes.py new file mode 100644 index 0000000..5371802 --- /dev/null +++ b/app/admin/plans/routes.py @@ -0,0 +1,7 @@ +""" +app/admin/plans/routes.py +Phase 2 implementation. +""" +from flask import Blueprint + +plans_bp = Blueprint("plans", __name__, url_prefix="/plans") diff --git a/app/admin/settings_override/__init__.py b/app/admin/settings_override/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/settings_override/routes.py b/app/admin/settings_override/routes.py new file mode 100644 index 0000000..4857ce1 --- /dev/null +++ b/app/admin/settings_override/routes.py @@ -0,0 +1,7 @@ +""" +app/admin/settings_override/routes.py +Phase 2 implementation. +""" +from flask import Blueprint + +settings_override_bp = Blueprint("settings_override", __name__, url_prefix="/settings-override") diff --git a/app/admin/system_users/__init__.py b/app/admin/system_users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/system_users/routes.py b/app/admin/system_users/routes.py new file mode 100644 index 0000000..4009ee9 --- /dev/null +++ b/app/admin/system_users/routes.py @@ -0,0 +1,7 @@ +""" +app/admin/system_users/routes.py +Phase 2 implementation. +""" +from flask import Blueprint + +system_users_bp = Blueprint("system_users", __name__, url_prefix="/system-users") diff --git a/app/admin/tenants/__init__.py b/app/admin/tenants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/tenants/routes.py b/app/admin/tenants/routes.py new file mode 100644 index 0000000..b01e136 --- /dev/null +++ b/app/admin/tenants/routes.py @@ -0,0 +1,7 @@ +""" +app/admin/tenants/routes.py +Phase 2 implementation. +""" +from flask import Blueprint + +tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants") diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/auth/__init__.py b/app/api/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context.py b/app/context.py new file mode 100644 index 0000000..42582ae --- /dev/null +++ b/app/context.py @@ -0,0 +1,126 @@ +""" +context.py — Tenant and location context resolution. +load_tenant_context() and load_location_context() are registered +as before_request hooks in the tenant app factory. +""" + +import logging +from flask import g, session, redirect, url_for, request, abort +from flask_login import current_user +from app.models.platform import Tenant +from app.models.salon import Location, StaffLocation, Staff + +logger = logging.getLogger(__name__) + + +def load_tenant_context(): + """ + Resolve g.tenant from the authenticated user's session. + Called on every request in the tenant portal. + Redirects to a locked page if the tenant is suspended or cancelled. + Skips public routes (checkin, booking, staff-login, auth). + """ + # Skip context resolution for public / auth endpoints + public_blueprints = {"tenant_auth", "staff_auth", "checkin", "booking", "static"} + if request.blueprint in public_blueprints: + g.tenant = None + return + + if not current_user.is_authenticated: + g.tenant = None + return + + # Resolve tenant_id from the authenticated principal + if hasattr(current_user, "tenant_id"): + tenant_id = current_user.tenant_id + else: + g.tenant = None + return + + tenant = Tenant.query.get(tenant_id) + if tenant is None: + logger.warning("Tenant %s not found for user %s", tenant_id, current_user) + abort(403) + + g.tenant = tenant + + # Enforce subscription status + if tenant.status == "suspended": + if request.endpoint != "tenant_auth.suspended": + return redirect(url_for("tenant_auth.suspended")) + + if tenant.status == "cancelled": + if request.endpoint != "tenant_auth.cancelled": + return redirect(url_for("tenant_auth.cancelled")) + + logger.debug("Tenant context loaded: %s", tenant.slug) + + +def load_location_context(): + """ + Resolve g.location from session or default to the tenant's primary location. + For tenant_staff, validates that the staff member is assigned to the location. + Must be called after load_tenant_context(). + """ + if not hasattr(g, "tenant") or g.tenant is None: + g.location = None + return + + if not current_user.is_authenticated: + g.location = None + return + + tenant_id = g.tenant.id + + # Try to load from session + location_id = session.get("active_location_id") + + if location_id: + location = Location.query.filter_by( + id=location_id, + tenant_id=tenant_id, + is_active=True, + ).filter(Location.deleted_at.is_(None)).first() + else: + location = None + + # Fall back to primary location + if location is None: + location = Location.query.filter_by( + tenant_id=tenant_id, + is_primary=True, + is_active=True, + ).filter(Location.deleted_at.is_(None)).first() + + if location is None: + # Last resort: first active location + location = Location.query.filter_by( + tenant_id=tenant_id, + is_active=True, + ).filter(Location.deleted_at.is_(None)).first() + + if location: + session["active_location_id"] = location.id + + # For tenant_staff: enforce location assignment + if location and hasattr(current_user, "get_id"): + user_id_str = current_user.get_id() + if user_id_str and user_id_str.startswith("staff:"): + staff_id = int(user_id_str.split(":")[1]) + assigned = StaffLocation.query.filter_by( + staff_id=staff_id, + location_id=location.id, + tenant_id=tenant_id, + ).first() + if not assigned: + logger.warning( + "Staff %s attempted access to unassigned location %s", + staff_id, location.id, + ) + abort(403) + + g.location = location + logger.debug( + "Location context loaded: %s", + location.name if location else "None", + ) diff --git a/app/decorators.py b/app/decorators.py new file mode 100644 index 0000000..6c64e5f --- /dev/null +++ b/app/decorators.py @@ -0,0 +1,126 @@ +""" +decorators.py — Shared route decorators. + @require_role(*roles) — Enforce system role on a route. + @tenant_feature_required(flag) — Gate a route behind a plan feature flag. + @demo_readonly — Block write operations on the demo tenant. +""" + +import logging +import functools +from flask import g, abort, jsonify, request +from flask_login import current_user + +logger = logging.getLogger(__name__) + + +def require_role(*roles): + """ + Decorator that enforces the current user holds one of the specified roles. + Works for both system_users (superadmin) and tenant users / staff. + + Usage: + @require_role("tenant_admin") + @require_role("tenant_admin", "tenant_manager") + @require_role("superadmin") + """ + def decorator(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + if not current_user.is_authenticated: + abort(401) + + user_role = getattr(current_user, "role", None) + if user_role not in roles: + logger.warning( + "Role check failed: user %s has role '%s', required one of %s", + getattr(current_user, "id", "?"), user_role, roles, + ) + abort(403) + return fn(*args, **kwargs) + return wrapper + return decorator + + +def tenant_feature_required(flag: str): + """ + Decorator that gates a route behind a plan feature flag. + Checks g.tenant.plan.features_json for the given flag. + Also respects tenant_setting_overrides for force-enable/disable. + + Usage: + @tenant_feature_required("marketing") + @tenant_feature_required("inventory") + """ + def decorator(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + tenant = getattr(g, "tenant", None) + if tenant is None: + abort(403) + + # Check for superadmin override (force-enable / force-disable) + from app.models.platform import TenantSettingOverride + override = TenantSettingOverride.query.filter_by( + tenant_id=tenant.id, + setting_key=f"feature.{flag}", + lifted_at=None, + ).first() + + if override is not None: + enabled = override.setting_value in ("1", "true", "True") + if not enabled: + logger.info( + "Feature '%s' force-disabled by admin override for tenant %s", + flag, tenant.slug, + ) + abort(403) + # force-enabled: proceed regardless of plan + return fn(*args, **kwargs) + + # Check plan feature flags + if tenant.plan is None or not tenant.plan.has_feature(flag): + logger.info( + "Feature '%s' not available on plan '%s' for tenant %s", + flag, + tenant.plan.name if tenant.plan else "unknown", + tenant.slug, + ) + abort(403) + + return fn(*args, **kwargs) + return wrapper + return decorator + + +def demo_readonly(fn): + """ + Decorator that blocks all write operations (POST, PUT, PATCH, DELETE) + when the active tenant is the demo tenant. + Returns 403 with a JSON or HTML response depending on the request type. + + Usage: + @demo_readonly + def create_customer(): + ... + """ + @functools.wraps(fn) + def wrapper(*args, **kwargs): + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + tenant = getattr(g, "tenant", None) + if tenant and tenant.is_demo: + logger.info( + "Demo write blocked: %s %s", request.method, request.path + ) + if request.is_json or request.path.startswith("/api/"): + return jsonify( + error="Demo account is read-only. " + "Sign up for a full account to make changes." + ), 403 + from flask import flash, redirect, request as req + flash( + "This is a demo account. Sign up for a full account to make changes.", + "warning", + ) + return redirect(req.referrer or "/") + return fn(*args, **kwargs) + return wrapper diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..ae1ac5a --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,36 @@ +""" +extensions.py — Shared Flask extension instances. +Imported by both app factories to avoid circular imports. +""" + +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_jwt_extended import JWTManager +from flask_wtf.csrf import CSRFProtect +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_mail import Mail +from flask_apscheduler import APScheduler +from flask_bcrypt import Bcrypt + +db = SQLAlchemy() +migrate = Migrate() +csrf = CSRFProtect() +mail = Mail() +scheduler = APScheduler() +bcrypt = Bcrypt() + +# Two separate LoginManager instances — one per app factory. +admin_login_manager = LoginManager() +tenant_login_manager = LoginManager() + +# Two separate JWTManager instances — one per app factory. +admin_jwt = JWTManager() +tenant_jwt = JWTManager() + +# Rate limiter — shared, keyed by remote address. +limiter = Limiter(key_func=get_remote_address) + +jwt = admin_jwt # shared alias used by both app factories + diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..c88037a --- /dev/null +++ b/app/forms.py @@ -0,0 +1,39 @@ +""" +forms.py +Shared form utilities and password strength validator. +Per-module WTForms classes live in their respective blueprint directories. +""" + +import re + + +_PASSWORD_MIN_LENGTH = 10 +_HAS_UPPERCASE = re.compile(r"[A-Z]") +_HAS_LOWERCASE = re.compile(r"[a-z]") +_HAS_DIGIT = re.compile(r"\d") + + +def validate_password_strength(password: str, confirm: str = None) -> str | None: + """ + Validate password strength per the security policy: + - Minimum 10 characters + - Must include at least one uppercase letter + - Must include at least one lowercase letter + - Must include at least one digit + + Returns an error string, or None if the password is acceptable. + If confirm is supplied, also checks they match. + """ + if not password: + return "Password is required." + if len(password) < _PASSWORD_MIN_LENGTH: + return f"Password must be at least {_PASSWORD_MIN_LENGTH} characters." + if not _HAS_UPPERCASE.search(password): + return "Password must include at least one uppercase letter." + if not _HAS_LOWERCASE.search(password): + return "Password must include at least one lowercase letter." + if not _HAS_DIGIT.search(password): + return "Password must include at least one digit." + if confirm is not None and password != confirm: + return "Passwords do not match." + return None diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..db3d4b3 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,44 @@ +""" +models/__init__.py +Import all models so Flask-Migrate can discover them. +JWTBlocklist lives in platform.py (platform-level table). +""" + +from app.models.platform import ( # noqa: F401 + SystemUser, + Plan, + Tenant, + TenantBillingHistory, + TenantSettingOverride, + AuditLog, + JWTBlocklist, +) + +from app.models.salon import ( # noqa: F401 + TenantSetting, + Location, + LocationSetting, + User, + Customer, + Service, + Product, + Promotion, + Staff, + StaffLocation, + StaffSchedule, + Appointment, + Transaction, + TransactionItem, + Inventory, + InventoryLog, + CommissionLog, + StaffPayPeriod, + StaffClocking, + MarketingCampaign, + GiftCard, + CheckinQueue, + Waitlist, + CheckoutReview, + DailyReconciliation, + AppointmentReminder, +) diff --git a/app/models/platform.py b/app/models/platform.py new file mode 100644 index 0000000..afcc09d --- /dev/null +++ b/app/models/platform.py @@ -0,0 +1,275 @@ +""" +models/platform.py — Platform-level models (superadmin scope). +Tables: system_users, plans, tenants, tenant_billing_history, + tenant_setting_overrides, audit_log, jwt_blocklist +""" + +import logging +from datetime import datetime, timezone +from app.extensions import db + +logger = logging.getLogger(__name__) + + +class SystemUser(db.Model): + __tablename__ = "system_users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + name = db.Column(db.String(100), nullable=False) + role = db.Column(db.String(50), nullable=False, default="superadmin") + is_active = db.Column(db.Boolean, nullable=False, default=True) + failed_login_attempts = db.Column(db.Integer, nullable=False, default=0) + locked_until = db.Column(db.DateTime, nullable=True) + password_reset_token = db.Column(db.String(255), nullable=True) + password_reset_expires_at = db.Column(db.DateTime, nullable=True) + last_login_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + # Flask-Login interface + @property + def is_authenticated(self): + return True + + @property + def is_anonymous(self): + return False + + def get_id(self): + return f"system:{self.id}" + + def is_locked(self): + if self.locked_until is None: + return False + return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc) + + def record_login(self): + from datetime import datetime as _dt, timezone as _tz + self.failed_login_attempts = 0 + self.locked_until = None + self.last_login_at = _dt.now(_tz.utc) + + def record_failed_login(self, max_attempts: int, lockout_minutes: int): + from datetime import datetime as _dt, timezone as _tz, timedelta as _td + self.failed_login_attempts = (self.failed_login_attempts or 0) + 1 + if self.failed_login_attempts >= max_attempts: + self.locked_until = _dt.now(_tz.utc) + _td(minutes=lockout_minutes) + + def __repr__(self): + return f"" + + +class Plan(db.Model): + __tablename__ = "plans" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + price_monthly = db.Column(db.Numeric(8, 2), nullable=False) + max_staff = db.Column(db.Integer, nullable=True) # None = unlimited + max_locations = db.Column(db.Integer, nullable=True) # None = unlimited + features_json = db.Column(db.JSON, nullable=False, default=dict) + is_active = db.Column(db.Boolean, nullable=False, default=True) + + tenants = db.relationship("Tenant", back_populates="plan", lazy="dynamic") + + def has_feature(self, flag: str) -> bool: + return bool(self.features_json.get(flag, False)) + + def __repr__(self): + return f"" + + +class Tenant(db.Model): + __tablename__ = "tenants" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + name = db.Column(db.String(150), nullable=False) + owner_email = db.Column(db.String(255), nullable=False) + plan_id = db.Column(db.Integer, db.ForeignKey("plans.id"), nullable=False) + status = db.Column(db.String(20), nullable=False, default="trial") + # status: 'active' | 'trial' | 'suspended' | 'cancelled' + trial_ends_at = db.Column(db.DateTime, nullable=True) + subscription_expires_at = db.Column(db.DateTime, nullable=True) + is_demo = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc)) + + plan = db.relationship("Plan", back_populates="tenants") + billing_history = db.relationship( + "TenantBillingHistory", back_populates="tenant", lazy="dynamic" + ) + setting_overrides = db.relationship( + "TenantSettingOverride", back_populates="tenant", lazy="dynamic" + ) + + def is_suspended(self): + return self.status in ("suspended", "cancelled") + + def is_active_status(self): + return self.status in ("active", "trial") + + def get_setting(self, key: str, default=None): + """ + Resolve a tenant setting, honouring superadmin overrides. + Override takes precedence if lifted_at is NULL. + Falls back to tenant_settings, then the supplied default. + """ + override = ( + TenantSettingOverride.query + .filter_by(tenant_id=self.id, setting_key=key) + .filter(TenantSettingOverride.lifted_at.is_(None)) + .first() + ) + if override: + return override.setting_value + + from app.models.salon import TenantSetting + setting = TenantSetting.query.filter_by( + tenant_id=self.id, setting_key=key + ).first() + return setting.setting_value if setting else default + + def has_feature(self, flag: str) -> bool: + """Check plan feature flag, allowing active overrides to force-enable/disable.""" + override = ( + TenantSettingOverride.query + .filter_by(tenant_id=self.id, setting_key=f"feature_{flag}") + .filter(TenantSettingOverride.lifted_at.is_(None)) + .first() + ) + if override: + return override.setting_value.lower() in ("true", "1", "yes") + return self.plan.has_feature(flag) if self.plan else False + + def __repr__(self): + return f"" + + +class TenantBillingHistory(db.Model): + __tablename__ = "tenant_billing_history" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + amount = db.Column(db.Numeric(10, 2), nullable=False) + description = db.Column(db.String(255), nullable=False) + paid_at = db.Column(db.DateTime, nullable=True) + invoice_ref = db.Column(db.String(100), nullable=True) + recorded_by = db.Column(db.Integer, db.ForeignKey("system_users.id"), + nullable=True) + + tenant = db.relationship("Tenant", back_populates="billing_history") + recorder = db.relationship("SystemUser") + + def __repr__(self): + return f"" + + +class TenantSettingOverride(db.Model): + __tablename__ = "tenant_setting_overrides" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + setting_key = db.Column(db.String(100), nullable=False) + setting_value = db.Column(db.Text, nullable=True) + overridden_by = db.Column(db.Integer, db.ForeignKey("system_users.id"), + nullable=False) + overridden_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + lifted_at = db.Column(db.DateTime, nullable=True) + note = db.Column(db.Text, nullable=True) + + tenant = db.relationship("Tenant", back_populates="setting_overrides") + admin = db.relationship("SystemUser") + + @property + def is_active(self): + return self.lifted_at is None + + def __repr__(self): + return f"" + + +class AuditLog(db.Model): + """ + Immutable, append-only audit log. + Never issue UPDATE or DELETE against this table from application code. + Retention: records older than 365 days purged monthly via APScheduler. + """ + __tablename__ = "audit_log" + + id = db.Column(db.Integer, primary_key=True) + actor_id = db.Column(db.Integer, nullable=False) + actor_type = db.Column(db.String(50), nullable=False) + # actor_type: 'system_user' | 'tenant_user' + action = db.Column(db.String(100), nullable=False) + target_type = db.Column(db.String(100), nullable=True) + target_id = db.Column(db.Integer, nullable=True) + before_json = db.Column(db.JSON, nullable=True) + after_json = db.Column(db.JSON, nullable=True) + ip_address = db.Column(db.String(45), nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc), + index=True) + + @classmethod + def log(cls, actor_id, actor_type, action, + target_type=None, target_id=None, + before=None, after=None, ip_address=None): + """ + Helper to append an audit entry and flush to DB. + Usage: + AuditLog.log( + actor_id=current_user.id, + actor_type='system_user', + action='tenant.suspend', + target_type='tenant', + target_id=tenant.id, + before={'status': 'active'}, + after={'status': 'suspended'}, + ip_address=request.remote_addr, + ) + """ + entry = cls( + actor_id=actor_id, + actor_type=actor_type, + action=action, + target_type=target_type, + target_id=target_id, + before_json=before, + after_json=after, + ip_address=ip_address, + ) + db.session.add(entry) + logger.info( + "AUDIT | actor=%s(%s) action=%s target=%s/%s", + actor_type, actor_id, action, target_type, target_id, + ) + return entry + + def __repr__(self): + return f"" + + +class JWTBlocklist(db.Model): + """ + Stores revoked JWT refresh token JTIs. + Checked on every token refresh request. + """ + __tablename__ = "jwt_blocklist" + + id = db.Column(db.Integer, primary_key=True) + jti = db.Column(db.String(36), nullable=False, unique=True, index=True) + token_type = db.Column(db.String(20), nullable=False, default="refresh") + revoked_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + def __repr__(self): + return f"" diff --git a/app/models/salon.py b/app/models/salon.py new file mode 100644 index 0000000..4c2e198 --- /dev/null +++ b/app/models/salon.py @@ -0,0 +1,744 @@ +""" +models/salon.py — Tenant-level models (all carry tenant_id). +Soft delete convention: all tenant models include deleted_at. +Queries always filter WHERE deleted_at IS NULL. +""" + +import logging +from datetime import datetime, timezone +from app.extensions import db + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────── +# User & Location +# ───────────────────────────────────────────────────────────── + +class User(db.Model): + """Tenant admin and manager accounts (email + password login).""" + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + email = db.Column(db.String(255), nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(30), nullable=False) + # role: 'tenant_admin' | 'tenant_manager' + is_active = db.Column(db.Boolean, nullable=False, default=True) + failed_login_attempts = db.Column(db.Integer, nullable=False, default=0) + locked_until = db.Column(db.DateTime, nullable=True) + password_reset_token = db.Column(db.String(255), nullable=True) + password_reset_expires_at = db.Column(db.DateTime, nullable=True) + last_login_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + deleted_at = db.Column(db.DateTime, nullable=True) + + __table_args__ = ( + db.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"), + ) + + # Flask-Login interface + @property + def is_authenticated(self): + return True + + @property + def is_anonymous(self): + return False + + def get_id(self): + return f"user:{self.id}" + + def is_locked(self): + if self.locked_until is None: + return False + return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc) + + def record_login(self): + self.failed_login_attempts = 0 + self.locked_until = None + self.last_login_at = datetime.now(timezone.utc) + + def record_failed_login(self, max_attempts: int, lockout_minutes: int): + from datetime import timedelta + self.failed_login_attempts = (self.failed_login_attempts or 0) + 1 + if self.failed_login_attempts >= max_attempts: + self.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes) + + def __repr__(self): + return f"" + + +class TenantSetting(db.Model): + __tablename__ = "tenant_settings" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + setting_key = db.Column(db.String(100), nullable=False) + setting_value = db.Column(db.Text, nullable=True) + + __table_args__ = ( + db.UniqueConstraint("tenant_id", "setting_key", + name="uq_tenant_settings_key"), + ) + + def __repr__(self): + return f"" + + +class Location(db.Model): + __tablename__ = "locations" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + name = db.Column(db.String(150), nullable=False) + address = db.Column(db.String(255), nullable=True) + phone = db.Column(db.String(30), nullable=True) + email = db.Column(db.String(255), nullable=True) + timezone = db.Column(db.String(60), nullable=False, default="America/New_York") + is_active = db.Column(db.Boolean, nullable=False, default=True) + is_primary = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + deleted_at = db.Column(db.DateTime, nullable=True) + + settings = db.relationship("LocationSetting", back_populates="location", + lazy="dynamic") + + def __repr__(self): + return f"" + + +class LocationSetting(db.Model): + __tablename__ = "location_settings" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + setting_key = db.Column(db.String(100), nullable=False) + setting_value = db.Column(db.Text, nullable=True) + + location = db.relationship("Location", back_populates="settings") + + __table_args__ = ( + db.UniqueConstraint("location_id", "setting_key", + name="uq_location_settings_key"), + ) + + +# ───────────────────────────────────────────────────────────── +# Customers +# ───────────────────────────────────────────────────────────── + +class Customer(db.Model): + __tablename__ = "customers" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + name = db.Column(db.String(150), nullable=False) + phone = db.Column(db.String(30), nullable=True) + email = db.Column(db.String(255), nullable=True) + date_of_birth = db.Column(db.Date, nullable=True) + preferred_staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), + nullable=True) + notes = db.Column(db.Text, nullable=True) + loyalty_points = db.Column(db.Integer, nullable=False, default=0) + is_active = db.Column(db.Boolean, nullable=False, default=True) + no_show_count = db.Column(db.Integer, nullable=False, default=0) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + deleted_at = db.Column(db.DateTime, nullable=True) + + def soft_delete(self): + self.deleted_at = datetime.now(timezone.utc) + + def __repr__(self): + return f"" + + +# ───────────────────────────────────────────────────────────── +# Services, Products & Promotions +# ───────────────────────────────────────────────────────────── + +class Service(db.Model): + __tablename__ = "services" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + name = db.Column(db.String(150), nullable=False) + category = db.Column(db.String(100), nullable=True) + duration_min = db.Column(db.Integer, nullable=False, default=30) + price = db.Column(db.Numeric(8, 2), nullable=False) + is_active = db.Column(db.Boolean, nullable=False, default=True) + deleted_at = db.Column(db.DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class Product(db.Model): + __tablename__ = "products" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + name = db.Column(db.String(150), nullable=False) + sku = db.Column(db.String(100), nullable=True) + category = db.Column(db.String(100), nullable=True) + sale_price = db.Column(db.Numeric(8, 2), nullable=False) + is_active = db.Column(db.Boolean, nullable=False, default=True) + deleted_at = db.Column(db.DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class Promotion(db.Model): + """ + applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all' + target_ids_json: list of IDs when applies_to is 'service' or 'product'; else null. + discount_percent: 1–100 integer. + Active window: starts_at <= NOW() <= ends_at AND is_active = true. + """ + __tablename__ = "promotions" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + name = db.Column(db.String(150), nullable=False) + discount_percent = db.Column(db.Integer, nullable=False) + applies_to = db.Column(db.String(30), nullable=False) + target_ids_json = db.Column(db.JSON, nullable=True) + starts_at = db.Column(db.DateTime, nullable=False) + ends_at = db.Column(db.DateTime, nullable=False) + is_active = db.Column(db.Boolean, nullable=False, default=True) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + def __repr__(self): + return f"" + + +# ───────────────────────────────────────────────────────────── +# Staff +# ───────────────────────────────────────────────────────────── + +class Staff(db.Model): + """ + staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist' + pay_type: 'hourly' | 'salary' | 'guarantee' + pay_period: 'weekly' | 'biweekly' | 'monthly' + passcode_hash: bcrypt-hashed 4–6 digit PIN; set by tenant_admin at creation. + phone: unique within tenant; used for staff-login. + """ + __tablename__ = "staff" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + name = db.Column(db.String(150), nullable=False) + phone = db.Column(db.String(30), nullable=False) + passcode_hash = db.Column(db.String(255), nullable=False) + staff_type = db.Column(db.String(30), nullable=False) + pay_type = db.Column(db.String(20), nullable=False, default="hourly") + hourly_rate = db.Column(db.Numeric(8, 2), nullable=True) + salary_amount = db.Column(db.Numeric(10, 2), nullable=True) + guarantee_amount = db.Column(db.Numeric(10, 2), nullable=True) + pay_period = db.Column(db.String(20), nullable=False, default="biweekly") + commission_rate = db.Column(db.Numeric(5, 2), nullable=True) + commission_enabled = db.Column(db.Boolean, nullable=False, default=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + passcode_failed_attempts = db.Column(db.Integer, nullable=False, default=0) + passcode_locked_until = db.Column(db.DateTime, nullable=True) + deleted_at = db.Column(db.DateTime, nullable=True) + + locations = db.relationship("StaffLocation", back_populates="staff", + lazy="dynamic") + schedules = db.relationship("StaffSchedule", back_populates="staff", + lazy="dynamic") + + __table_args__ = ( + db.UniqueConstraint("tenant_id", "phone", name="uq_staff_tenant_phone"), + ) + + # Flask-Login interface (for staff portal session) + @property + def is_authenticated(self): + return True + + @property + def is_anonymous(self): + return False + + def get_id(self): + return f"staff:{self.id}" + + def is_passcode_locked(self): + if self.passcode_locked_until is None: + return False + return datetime.now(timezone.utc) < self.passcode_locked_until.replace( + tzinfo=timezone.utc + ) + + def record_passcode_success(self): + self.passcode_failed_attempts = 0 + self.passcode_locked_until = None + + def record_passcode_failure(self, max_attempts: int, lockout_minutes: int): + from datetime import timedelta + self.passcode_failed_attempts = (self.passcode_failed_attempts or 0) + 1 + if self.passcode_failed_attempts >= max_attempts: + self.passcode_locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes) + + def soft_delete(self): + self.deleted_at = datetime.now(timezone.utc) + + def is_assigned_to_location(self, location_id: int) -> bool: + return any(loc.id == location_id for loc in self.locations) + + def __repr__(self): + return f"" + + +class StaffLocation(db.Model): + __tablename__ = "staff_locations" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + + staff = db.relationship("Staff", back_populates="locations") + location = db.relationship("Location") + + __table_args__ = ( + db.UniqueConstraint("staff_id", "location_id", + name="uq_staff_location"), + ) + + +class StaffSchedule(db.Model): + __tablename__ = "staff_schedules" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + day_of_week = db.Column(db.Integer, nullable=False) # 0=Mon … 6=Sun + start_time = db.Column(db.Time, nullable=False) + end_time = db.Column(db.Time, nullable=False) + + staff = db.relationship("Staff", back_populates="schedules") + + +# ───────────────────────────────────────────────────────────── +# Appointments +# ───────────────────────────────────────────────────────────── + +class Appointment(db.Model): + """ + status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show' + rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk' | null + """ + __tablename__ = "appointments" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), + nullable=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True) + service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True) + start_time = db.Column(db.DateTime, nullable=False) + end_time = db.Column(db.DateTime, nullable=True) + is_walk_in = db.Column(db.Boolean, nullable=False, default=False) + status = db.Column(db.String(20), nullable=False, default="pending") + notes = db.Column(db.Text, nullable=True) + cancellation_reason = db.Column(db.Text, nullable=True) + cancelled_at = db.Column(db.DateTime, nullable=True) + rebook_source = db.Column(db.String(20), nullable=True) + rebooked_from_transaction_id = db.Column( + db.Integer, db.ForeignKey("transactions.id"), nullable=True + ) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + customer = db.relationship("Customer") + staff = db.relationship("Staff") + service = db.relationship("Service") + + def __repr__(self): + return f"" + + +# ───────────────────────────────────────────────────────────── +# Transactions & POS +# ───────────────────────────────────────────────────────────── + +class Transaction(db.Model): + """ + payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other' + """ + __tablename__ = "transactions" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"), + nullable=True) + customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), + nullable=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True) + subtotal = db.Column(db.Numeric(10, 2), nullable=False) + discount = db.Column(db.Numeric(10, 2), nullable=False, default=0) + tip_amount = db.Column(db.Numeric(8, 2), nullable=False, default=0) + gift_card_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) + total = db.Column(db.Numeric(10, 2), nullable=False) + payment_method = db.Column(db.String(20), nullable=False) + payment_reference = db.Column(db.String(255), nullable=True) + gift_card_id = db.Column(db.Integer, db.ForeignKey("gift_cards.id"), + nullable=True) + review_request_sent_at = db.Column(db.DateTime, nullable=True) + voided_at = db.Column(db.DateTime, nullable=True) + voided_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + void_reason = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + items = db.relationship("TransactionItem", back_populates="transaction", + lazy="dynamic") + + def __repr__(self): + return f"" + + +class TransactionItem(db.Model): + __tablename__ = "transaction_items" + + id = db.Column(db.Integer, primary_key=True) + transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"), + nullable=False, index=True) + service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=True) + qty = db.Column(db.Integer, nullable=False, default=1) + unit_price = db.Column(db.Numeric(8, 2), nullable=False) + # unit_price: final price after promotion + original_price = db.Column(db.Numeric(8, 2), nullable=False) + # original_price: price before promotion + discount_percent = db.Column(db.Numeric(5, 2), nullable=False, default=0) + promotion_id = db.Column(db.Integer, db.ForeignKey("promotions.id"), + nullable=True) + + transaction = db.relationship("Transaction", back_populates="items") + + def __repr__(self): + return f"" + + +# ───────────────────────────────────────────────────────────── +# Inventory +# ───────────────────────────────────────────────────────────── + +class Inventory(db.Model): + __tablename__ = "inventory" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + name = db.Column(db.String(150), nullable=False) + sku = db.Column(db.String(100), nullable=True) + category = db.Column(db.String(100), nullable=True) + qty_on_hand = db.Column(db.Integer, nullable=False, default=0) + reorder_level = db.Column(db.Integer, nullable=False, default=5) + cost_price = db.Column(db.Numeric(8, 2), nullable=True) + sale_price = db.Column(db.Numeric(8, 2), nullable=True) + + def __repr__(self): + return f"" + + +class InventoryLog(db.Model): + __tablename__ = "inventory_log" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + inventory_id = db.Column(db.Integer, db.ForeignKey("inventory.id"), + nullable=False) + delta = db.Column(db.Integer, nullable=False) + reason = db.Column(db.String(255), nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + +# ───────────────────────────────────────────────────────────── +# Commission & Pay +# ───────────────────────────────────────────────────────────── + +class CommissionLog(db.Model): + __tablename__ = "commission_log" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), + nullable=False, index=True) + transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"), + nullable=False) + amount = db.Column(db.Numeric(10, 2), nullable=False) + period = db.Column(db.String(20), nullable=True) + + +class StaffPayPeriod(db.Model): + """ + status: 'draft' | 'approved' | 'paid' + pay_type: mirrors staff.pay_type at the time of the period. + guarantee_topup: max(0, guarantee_amount - commission_amount) for 'guarantee' pay_type. + """ + __tablename__ = "staff_pay_periods" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), + nullable=False, index=True) + period_start = db.Column(db.Date, nullable=False) + period_end = db.Column(db.Date, nullable=False) + pay_type = db.Column(db.String(20), nullable=False) + base_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) + commission_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) + guarantee_topup = db.Column(db.Numeric(10, 2), nullable=False, default=0) + total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) + status = db.Column(db.String(20), nullable=False, default="draft") + notes = db.Column(db.Text, nullable=True) + + +class StaffClocking(db.Model): + """One row per shift. clocked_out_at NULL = currently clocked in.""" + __tablename__ = "staff_clockings" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), + nullable=False, index=True) + clocked_in_at = db.Column(db.DateTime, nullable=False) + clocked_out_at = db.Column(db.DateTime, nullable=True) + total_minutes = db.Column(db.Integer, nullable=True) + notes = db.Column(db.Text, nullable=True) + + +# ───────────────────────────────────────────────────────────── +# Marketing +# ───────────────────────────────────────────────────────────── + +class MarketingCampaign(db.Model): + """ + channel: 'email' (SMS deferred) + status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled' + """ + __tablename__ = "marketing_campaigns" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + name = db.Column(db.String(150), nullable=False) + channel = db.Column(db.String(20), nullable=False, default="email") + status = db.Column(db.String(20), nullable=False, default="draft") + audience_filter_json = db.Column(db.JSON, nullable=True) + subject = db.Column(db.String(255), nullable=True) + message_body = db.Column(db.Text, nullable=True) + scheduled_at = db.Column(db.DateTime, nullable=True) + sent_at = db.Column(db.DateTime, nullable=True) + sent_count = db.Column(db.Integer, nullable=False, default=0) + open_count = db.Column(db.Integer, nullable=False, default=0) + + +# ───────────────────────────────────────────────────────────── +# Gift Cards +# ───────────────────────────────────────────────────────────── + +class GiftCard(db.Model): + __tablename__ = "gift_cards" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + code = db.Column(db.String(50), nullable=False) + original_value = db.Column(db.Numeric(10, 2), nullable=False) + remaining_balance = db.Column(db.Numeric(10, 2), nullable=False) + issued_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + issued_to_customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), + nullable=True) + expires_at = db.Column(db.DateTime, nullable=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + __table_args__ = ( + db.UniqueConstraint("tenant_id", "code", name="uq_gift_card_tenant_code"), + ) + + +# ───────────────────────────────────────────────────────────── +# Check-in Kiosk & Waitlist +# ───────────────────────────────────────────────────────────── + +class CheckinQueue(db.Model): + """ + status: 'waiting' | 'acknowledged' | 'seated' | 'expired' + Surfaced to receptionist dashboard via 5-second polling. + """ + __tablename__ = "checkin_queue" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), + nullable=True) + customer_name = db.Column(db.String(150), nullable=False) + customer_phone = db.Column(db.String(30), nullable=False) + service_requested = db.Column(db.String(150), nullable=True) + checked_in_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + status = db.Column(db.String(20), nullable=False, default="waiting") + acknowledged_by = db.Column(db.Integer, db.ForeignKey("users.id"), + nullable=True) + acknowledged_at = db.Column(db.DateTime, nullable=True) + + +class Waitlist(db.Model): + """status: 'waiting' | 'notified' | 'booked' | 'expired'""" + __tablename__ = "waitlist" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + customer_name = db.Column(db.String(150), nullable=False) + customer_phone = db.Column(db.String(30), nullable=True) + customer_email = db.Column(db.String(255), nullable=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True) + service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True) + requested_date = db.Column(db.Date, nullable=True) + status = db.Column(db.String(20), nullable=False, default="waiting") + notified_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + +# ───────────────────────────────────────────────────────────── +# Reviews & Reconciliation +# ───────────────────────────────────────────────────────────── + +class CheckoutReview(db.Model): + """ + rating: 1–5 integer. + is_public_suggested: True if rating >= 4 (platform links shown). + """ + __tablename__ = "checkout_reviews" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"), + nullable=False) + customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), + nullable=True) + staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True) + rating = db.Column(db.Integer, nullable=False) + comment = db.Column(db.Text, nullable=True) + is_public_suggested = db.Column(db.Boolean, nullable=False, default=False) + google_clicked = db.Column(db.Boolean, nullable=False, default=False) + facebook_clicked = db.Column(db.Boolean, nullable=False, default=False) + yelp_clicked = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + +class DailyReconciliation(db.Model): + """variance = actual_cash_counted - expected_cash_in_drawer (negative = shortage)""" + __tablename__ = "daily_reconciliations" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + date = db.Column(db.Date, nullable=False) + total_cash = db.Column(db.Numeric(10, 2), nullable=False, default=0) + total_app_payments = db.Column(db.Numeric(10, 2), nullable=False, default=0) + total_tips = db.Column(db.Numeric(10, 2), nullable=False, default=0) + total_gift_card_redemptions = db.Column(db.Numeric(10, 2), nullable=False, + default=0) + expected_cash_in_drawer = db.Column(db.Numeric(10, 2), nullable=False, default=0) + actual_cash_counted = db.Column(db.Numeric(10, 2), nullable=True) + variance = db.Column(db.Numeric(10, 2), nullable=True) + closed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + closed_at = db.Column(db.DateTime, nullable=True) + notes = db.Column(db.Text, nullable=True) + + __table_args__ = ( + db.UniqueConstraint("tenant_id", "location_id", "date", + name="uq_reconciliation_location_date"), + ) + + +class AppointmentReminder(db.Model): + """ + reminder_type: '24h' | '2h' + channel: 'email' (SMS deferred) + status: 'pending' | 'sent' | 'failed' | 'cancelled' + """ + __tablename__ = "appointment_reminders" + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), + nullable=False, index=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), + nullable=False, index=True) + appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"), + nullable=False, index=True) + reminder_type = db.Column(db.String(10), nullable=False) + scheduled_for = db.Column(db.DateTime, nullable=False) + sent_at = db.Column(db.DateTime, nullable=True) + channel = db.Column(db.String(20), nullable=False, default="email") + status = db.Column(db.String(20), nullable=False, default="pending") + + + diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..4e29d61 --- /dev/null +++ b/app/security.py @@ -0,0 +1,99 @@ +""" +security.py — Security middleware and input sanitisation helpers. + - apply_security_headers(app) — Attach security headers to every response. + - check_admin_ip(app) — Enforce ADMIN_IP_ALLOWLIST at the Flask layer. + - sanitise_string(value) — Strip control characters from user input. + - validate_slug(slug) — Validate tenant slug format. +""" + +import re +import logging +import ipaddress +from flask import request, abort, current_app + +logger = logging.getLogger(__name__) + +# Regex: slugs must be lowercase alphanumeric + hyphens, 2–80 chars +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{1,78}[a-z0-9]$") + +# Strip ASCII control characters (0x00–0x1F, 0x7F) and Unicode C0/C1 blocks +_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f\x80-\x9f]") + + +def apply_security_headers(app): + """ + Register an after_request hook that attaches security headers to every + response. Nginx adds HSTS and the more restrictive CSP for production; + this layer provides defence-in-depth and covers the development server. + """ + @app.after_request + def _add_headers(response): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + # Basic CSP — tightened further in Nginx for production + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "frame-ancestors 'none';" + ) + return response + + +def check_admin_ip(app): + """ + Register a before_request hook on the admin app that validates the + client IP against ADMIN_IP_ALLOWLIST. Provides a Flask-layer double-check + behind Nginx's allow/deny directives. + """ + @app.before_request + def _check_ip(): + allowlist_raw = app.config.get("ADMIN_IP_ALLOWLIST", "") + if not allowlist_raw.strip(): + return # No allowlist configured — skip check (dev mode) + + networks = [] + for cidr in allowlist_raw.split(","): + cidr = cidr.strip() + if cidr: + try: + networks.append(ipaddress.ip_network(cidr, strict=False)) + except ValueError: + logger.error("Invalid CIDR in ADMIN_IP_ALLOWLIST: %s", cidr) + + if not networks: + return + + client_ip_str = request.headers.get("X-Real-IP") or request.remote_addr + try: + client_ip = ipaddress.ip_address(client_ip_str) + except ValueError: + logger.warning("Unparseable client IP: %s", client_ip_str) + abort(403) + return + + if not any(client_ip in net for net in networks): + logger.warning( + "Admin IP blocked: %s not in allowlist", client_ip_str + ) + abort(403) + + +def sanitise_string(value: str, max_length: int = None) -> str: + """ + Strip control characters from a user-supplied string. + Optionally truncate to max_length. + """ + if not isinstance(value, str): + return value + cleaned = _CONTROL_CHAR_RE.sub("", value).strip() + if max_length: + cleaned = cleaned[:max_length] + return cleaned + + +def validate_slug(slug: str) -> bool: + """Return True if the slug matches the allowed pattern.""" + return bool(_SLUG_RE.match(slug)) diff --git a/app/static/admin/css/main.css b/app/static/admin/css/main.css new file mode 100644 index 0000000..b2bc4fc --- /dev/null +++ b/app/static/admin/css/main.css @@ -0,0 +1,2 @@ +/* Admin portal custom styles — extended in later phases */ +body { background-color: #f4f6f9; } diff --git a/app/static/tenant/css/main.css b/app/static/tenant/css/main.css new file mode 100644 index 0000000..e6207d9 --- /dev/null +++ b/app/static/tenant/css/main.css @@ -0,0 +1,2 @@ +/* Tenant portal custom styles — extended in later phases */ +body { background-color: #f8f9fa; } diff --git a/app/templates/admin/auth/login.html b/app/templates/admin/auth/login.html new file mode 100644 index 0000000..45c4c25 --- /dev/null +++ b/app/templates/admin/auth/login.html @@ -0,0 +1,28 @@ +{% extends "admin/base.html" %} +{% block title %}Admin Login{% endblock %} +{% block content %} +
+
+
+
+

Admin Portal

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ +
+ + +
+
+ + +
+ +
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html new file mode 100644 index 0000000..5e19f7a --- /dev/null +++ b/app/templates/admin/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}Admin Portal{% endblock %} — Nails Salon POS + + + + + {% if current_user.is_authenticated %} + + {% endif %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/app/templates/tenant/auth/cancelled.html b/app/templates/tenant/auth/cancelled.html new file mode 100644 index 0000000..5a19122 --- /dev/null +++ b/app/templates/tenant/auth/cancelled.html @@ -0,0 +1,10 @@ +{% extends "tenant/base.html" %} +{% block title %}Account Cancelled{% endblock %} +{% block content %} +
+
+

Account Cancelled

+

This account has been cancelled. Please contact support if you believe this is an error.

+
+
+{% endblock %} diff --git a/app/templates/tenant/auth/login.html b/app/templates/tenant/auth/login.html new file mode 100644 index 0000000..bb3bc2e --- /dev/null +++ b/app/templates/tenant/auth/login.html @@ -0,0 +1,34 @@ +{% extends "tenant/base.html" %} +{% block title %}Sign In{% endblock %} +{% block content %} +
+
+
+
+

Salon Login

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ +
+ + +
+
+ + +
+ +
+
+ +
+
+
+
+{% endblock %} diff --git a/app/templates/tenant/auth/password_reset_confirm.html b/app/templates/tenant/auth/password_reset_confirm.html new file mode 100644 index 0000000..915c896 --- /dev/null +++ b/app/templates/tenant/auth/password_reset_confirm.html @@ -0,0 +1,29 @@ +{% extends "tenant/base.html" %} +{% block title %}Set New Password{% endblock %} +{% block content %} +
+
+
+
+
Set New Password
+ {% if error %} +
{{ error }}
+ {% endif %} +
+ +
+ + +
Min 10 chars, must include uppercase, lowercase, and a digit.
+
+
+ + +
+ +
+
+
+
+
+{% endblock %} diff --git a/app/templates/tenant/auth/password_reset_request.html b/app/templates/tenant/auth/password_reset_request.html new file mode 100644 index 0000000..4449540 --- /dev/null +++ b/app/templates/tenant/auth/password_reset_request.html @@ -0,0 +1,28 @@ +{% extends "tenant/base.html" %} +{% block title %}Reset Password{% endblock %} +{% block content %} +
+
+
+
+
Reset Password
+ {% if message %} +
{{ message }}
+ {% else %} +
+ +
+ + +
+ +
+ {% endif %} + +
+
+
+
+{% endblock %} diff --git a/app/templates/tenant/auth/suspended.html b/app/templates/tenant/auth/suspended.html new file mode 100644 index 0000000..0a6c4d8 --- /dev/null +++ b/app/templates/tenant/auth/suspended.html @@ -0,0 +1,11 @@ +{% extends "tenant/base.html" %} +{% block title %}Account Suspended{% endblock %} +{% block content %} +
+
+

Account Suspended

+

Your salon account has been suspended. Please contact support to resolve your billing.

+ Back to Login +
+
+{% endblock %} diff --git a/app/templates/tenant/base.html b/app/templates/tenant/base.html new file mode 100644 index 0000000..972d35f --- /dev/null +++ b/app/templates/tenant/base.html @@ -0,0 +1,40 @@ + + + + + + {% block title %}Salon Portal{% endblock %} — Nails Salon POS + + + + + {% if current_user.is_authenticated %} + + {% endif %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/app/templates/tenant/checkin/kiosk.html b/app/templates/tenant/checkin/kiosk.html new file mode 100644 index 0000000..6320ab9 --- /dev/null +++ b/app/templates/tenant/checkin/kiosk.html @@ -0,0 +1,67 @@ + + + + + + Welcome to {{ tenant.name }} + + + + +
+ {% if confirmed %} +
+
+

You're checked in!

+

A staff member will be right with you.

+

Resetting in 10s…

+
+ + {% else %} +
+

Welcome to {{ tenant.name }}

+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+ {% if services %} +
+ + +
+ {% endif %} + +
+
+ {% endif %} +
+ + + diff --git a/app/templates/tenant/dashboard/index.html b/app/templates/tenant/dashboard/index.html new file mode 100644 index 0000000..7727702 --- /dev/null +++ b/app/templates/tenant/dashboard/index.html @@ -0,0 +1,10 @@ +{% extends "tenant/base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} +

Dashboard + {% if location %}— {{ location.name }}{% endif %} +

+
+ Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts). +
+{% endblock %} diff --git a/app/templates/tenant/staff_auth/login.html b/app/templates/tenant/staff_auth/login.html new file mode 100644 index 0000000..f555f5d --- /dev/null +++ b/app/templates/tenant/staff_auth/login.html @@ -0,0 +1,33 @@ +{% extends "tenant/base.html" %} +{% block title %}Staff Login{% endblock %} +{% block content %} +
+
+
+
+

Staff Login

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ +
+ + +
+
+ + +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/app/tenant/__init__.py b/app/tenant/__init__.py new file mode 100644 index 0000000..fb2154f --- /dev/null +++ b/app/tenant/__init__.py @@ -0,0 +1,105 @@ +""" +app/tenant/__init__.py — Tenant app factory (mydomain.com). +""" + +import logging +from datetime import timedelta +import os +from flask import Flask +from app.extensions import ( + db, migrate, csrf, mail, scheduler, + tenant_login_manager, tenant_jwt, limiter, +) +from app.security import apply_security_headers +from app.context import load_tenant_context, load_location_context +from config import get_config + +logger = logging.getLogger(__name__) + + +def create_tenant_app(config_override=None): + flask_app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", + static_url_path="/static/tenant", + ) + + # ── Config ──────────────────────────────────────────────── + cfg = config_override or get_config() + flask_app.config.from_object(cfg) + + flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_tenant_session" + flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta( + seconds=int(os.environ.get("SESSION_TIMEOUT_TENANT", 1800)) + ) + flask_app.config["SESSION_COOKIE_HTTPONLY"] = True + flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + + # ── Extensions ──────────────────────────────────────────── + db.init_app(flask_app) + migrate.init_app(flask_app, db) + csrf.init_app(flask_app) + mail.init_app(flask_app) + limiter.init_app(flask_app) + + # Tenant login manager + tenant_login_manager.login_view = "tenant_auth.login" + tenant_login_manager.login_message_category = "warning" + tenant_login_manager.session_protection = "strong" + tenant_login_manager.init_app(flask_app) + + # Tenant JWT + tenant_jwt.init_app(flask_app) + + # ── User loaders ────────────────────────────────────────── + from app.models.salon import User, Staff + + @tenant_login_manager.user_loader + def load_tenant_user(user_id: str): + if user_id.startswith("user:"): + try: + uid = int(user_id.split(":")[1]) + except (ValueError, IndexError): + return None + return User.query.filter_by(id=uid, is_active=True)\ + .filter(User.deleted_at.is_(None)).first() + + if user_id.startswith("staff:"): + try: + sid = int(user_id.split(":")[1]) + except (ValueError, IndexError): + return None + return Staff.query.filter_by(id=sid, is_active=True)\ + .filter(Staff.deleted_at.is_(None)).first() + + return None + + # ── Context hooks ───────────────────────────────────────── + flask_app.before_request(load_tenant_context) + flask_app.before_request(load_location_context) + + # ── Security ────────────────────────────────────────────── + apply_security_headers(flask_app) + + # ── Blueprints ──────────────────────────────────────────── + from app.tenant.auth.routes import tenant_auth_bp + from app.tenant.staff_auth.routes import staff_auth_bp + from app.tenant.checkin.routes import checkin_bp + from app.tenant.dashboard.routes import dashboard_bp + + flask_app.register_blueprint(tenant_auth_bp) + flask_app.register_blueprint(staff_auth_bp) + flask_app.register_blueprint(checkin_bp) + flask_app.register_blueprint(dashboard_bp) + + # Remaining blueprints registered in Phases 3–6: + # locations, customers, appointments, services, pos, staff, + # staff_portal, booking, waitlist, gift_cards, reviews, + # reconciliation, inventory, marketing, reports, settings + + # ── Import all models for Migrate ───────────────────────── + import app.models # noqa: F401 + + logger.info("Tenant app created (env=%s)", flask_app.config.get("FLASK_ENV")) + return flask_app diff --git a/app/tenant/appointments/__init__.py b/app/tenant/appointments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/appointments/routes.py b/app/tenant/appointments/routes.py new file mode 100644 index 0000000..e742b3c --- /dev/null +++ b/app/tenant/appointments/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/appointments/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments") diff --git a/app/tenant/auth/__init__.py b/app/tenant/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/auth/routes.py b/app/tenant/auth/routes.py new file mode 100644 index 0000000..72aaae4 --- /dev/null +++ b/app/tenant/auth/routes.py @@ -0,0 +1,198 @@ +""" +app/tenant/auth/routes.py — Tenant portal authentication (email + password). +Routes: /login, /logout, /password-reset, /suspended, /cancelled +Roles: tenant_admin, tenant_manager +""" + +import logging +import secrets +from datetime import datetime, timezone, timedelta +from flask import ( + Blueprint, render_template, redirect, url_for, + flash, request, current_app, session, g, +) +from flask_login import login_user, logout_user, login_required, current_user +import bcrypt + +from app.extensions import db, limiter, mail +from app.models.platform import Tenant +from app.models.salon import User +from flask_mail import Message + +logger = logging.getLogger(__name__) + +tenant_auth_bp = Blueprint("tenant_auth", __name__) + + +@tenant_auth_bp.route("/login", methods=["GET", "POST"]) +@limiter.limit("10 per minute") +def login(): + if current_user.is_authenticated: + return redirect(url_for("dashboard.index")) + + error = None + + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + + user = User.query.filter_by(email=email, is_active=True)\ + .filter(User.deleted_at.is_(None)).first() + + max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5) + lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) + + if user and user.is_locked(): + logger.warning("Tenant login blocked — account locked: %s", email) + error = f"Account locked. Try again in {lockout_minutes} minutes." + + elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()): + # Verify tenant is accessible + tenant = Tenant.query.get(user.tenant_id) + if not tenant or tenant.status == "cancelled": + error = "This account is no longer active." + else: + user.failed_login_attempts = 0 + user.locked_until = None + user.last_login_at = datetime.now(timezone.utc) + db.session.commit() + + login_user(user, remember=False) + session.permanent = True + + logger.info( + "Tenant login success: user=%s tenant=%s", + user.id, user.tenant_id, + ) + return redirect(url_for("dashboard.index")) + else: + if user: + user.failed_login_attempts += 1 + if user.failed_login_attempts >= max_attempts: + user.locked_until = datetime.now(timezone.utc) + timedelta( + minutes=lockout_minutes + ) + logger.warning( + "Tenant account locked after %d failures: %s", + max_attempts, email, + ) + db.session.commit() + + logger.warning("Tenant login failed: %s", email) + error = "Invalid email or password." + + return render_template("tenant/auth/login.html", error=error) + + +@tenant_auth_bp.route("/logout") +@login_required +def logout(): + logout_user() + session.clear() + logger.info("Tenant logout") + return redirect(url_for("tenant_auth.login")) + + +@tenant_auth_bp.route("/password-reset", methods=["GET", "POST"]) +@limiter.limit("5 per hour") +def password_reset_request(): + """Step 1: submit email → send reset link.""" + message = None + + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + user = User.query.filter_by(email=email)\ + .filter(User.deleted_at.is_(None)).first() + + if user: + token = secrets.token_urlsafe(32) + user.password_reset_token = token + user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + db.session.commit() + + reset_url = url_for( + "tenant_auth.password_reset_confirm", + token=token, + _external=True, + ) + try: + msg = Message( + subject="Reset your password", + recipients=[email], + body=f"Click the link below to reset your password (valid 1 hour):\n\n{reset_url}", + ) + mail.send(msg) + logger.info("Password reset email sent to %s", email) + except Exception as exc: + logger.error("Failed to send reset email to %s: %s", email, exc) + + # Always show the same message to prevent email enumeration + message = "If that email is registered, a reset link has been sent." + + return render_template( + "tenant/auth/password_reset_request.html", message=message + ) + + +@tenant_auth_bp.route("/password-reset/", methods=["GET", "POST"]) +def password_reset_confirm(token): + """Step 2: confirm token → set new password.""" + user = User.query.filter_by(password_reset_token=token)\ + .filter(User.deleted_at.is_(None)).first() + + now = datetime.now(timezone.utc) + + if not user or not user.password_reset_expires_at: + flash("Invalid or expired reset link.", "danger") + return redirect(url_for("tenant_auth.login")) + + expires = user.password_reset_expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + + if now > expires: + flash("This reset link has expired. Please request a new one.", "danger") + return redirect(url_for("tenant_auth.password_reset_request")) + + error = None + + if request.method == "POST": + new_password = request.form.get("password", "") + confirm = request.form.get("confirm_password", "") + + if new_password != confirm: + error = "Passwords do not match." + elif len(new_password) < 10: + error = "Password must be at least 10 characters." + elif not any(c.isupper() for c in new_password): + error = "Password must contain at least one uppercase letter." + elif not any(c.islower() for c in new_password): + error = "Password must contain at least one lowercase letter." + elif not any(c.isdigit() for c in new_password): + error = "Password must contain at least one digit." + else: + hashed = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt(rounds=12)) + user.password_hash = hashed.decode() + user.password_reset_token = None + user.password_reset_expires_at = None + user.failed_login_attempts = 0 + user.locked_until = None + db.session.commit() + + logger.info("Password reset completed for user %s", user.id) + flash("Password updated. Please log in.", "success") + return redirect(url_for("tenant_auth.login")) + + return render_template( + "tenant/auth/password_reset_confirm.html", error=error, token=token + ) + + +@tenant_auth_bp.route("/suspended") +def suspended(): + return render_template("tenant/auth/suspended.html"), 403 + + +@tenant_auth_bp.route("/cancelled") +def cancelled(): + return render_template("tenant/auth/cancelled.html"), 403 diff --git a/app/tenant/booking/__init__.py b/app/tenant/booking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/booking/routes.py b/app/tenant/booking/routes.py new file mode 100644 index 0000000..1a1f691 --- /dev/null +++ b/app/tenant/booking/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/booking/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +booking_bp = Blueprint("booking", __name__, url_prefix="") diff --git a/app/tenant/checkin/__init__.py b/app/tenant/checkin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/checkin/routes.py b/app/tenant/checkin/routes.py new file mode 100644 index 0000000..147b6e7 --- /dev/null +++ b/app/tenant/checkin/routes.py @@ -0,0 +1,135 @@ +""" +app/tenant/checkin/routes.py — Customer self check-in kiosk. +Route: GET/POST /checkin/ +No authentication required. CSRF-exempt. Rate-limited. +Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting. +""" + +import logging +from datetime import datetime, timezone +from flask import ( + Blueprint, render_template, request, jsonify, + current_app, abort, +) +from app.extensions import db, limiter, csrf +from app.models.platform import Tenant +from app.models.salon import Customer, CheckinQueue, Location +from app.security import sanitise_string, validate_slug + +logger = logging.getLogger(__name__) + +checkin_bp = Blueprint("checkin", __name__) + + +@checkin_bp.route("/checkin/", methods=["GET", "POST"]) +@csrf.exempt +@limiter.limit("20 per minute") +def kiosk(tenant_slug: str): + """ + Public kiosk page. No auth required. + Tenant slug validated against known slugs (not guessable). + Accepts: name, phone, service_requested — all other fields ignored. + Phone is sanitised and validated before profile lookup. + Page auto-resets after 10 seconds (configurable per tenant) via JS. + """ + # Validate slug format before hitting the DB + if not validate_slug(tenant_slug): + logger.warning("Kiosk: invalid slug format: %s", tenant_slug) + abort(404) + + tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first() + if not tenant or not tenant.is_active_status(): + logger.warning("Kiosk: tenant not found or inactive: %s", tenant_slug) + abort(404) + + # Resolve the primary location for this tenant + location = Location.query.filter_by( + tenant_id=tenant.id, + is_primary=True, + is_active=True, + ).filter(Location.deleted_at.is_(None)).first() + + if location is None: + abort(404) + + confirmed = False + + if request.method == "POST": + raw_name = request.form.get("customer_name", "") + raw_phone = request.form.get("customer_phone", "") + raw_service = request.form.get("service_requested", "") + + name = sanitise_string(raw_name, max_length=150) + phone = sanitise_string(raw_phone, max_length=30) + service = sanitise_string(raw_service, max_length=150) + + # Validate required fields + if not name or not phone: + return render_template( + "tenant/checkin/kiosk.html", + tenant=tenant, + error="Name and phone number are required.", + confirmed=False, + ) + + # Strip non-digit characters from phone for lookup consistency + phone_digits = "".join(filter(str.isdigit, phone)) + if len(phone_digits) < 7: + return render_template( + "tenant/checkin/kiosk.html", + tenant=tenant, + error="Please enter a valid phone number.", + confirmed=False, + ) + + # Look up or create customer profile + customer = Customer.query.filter_by( + tenant_id=tenant.id, + phone=phone_digits, + ).filter(Customer.deleted_at.is_(None)).first() + + if customer is None: + customer = Customer( + tenant_id=tenant.id, + name=name, + phone=phone_digits, + ) + db.session.add(customer) + db.session.flush() # get customer.id before committing + logger.info( + "Kiosk: new customer profile created for tenant %s", tenant.slug + ) + + # Queue the walk-in entry + entry = CheckinQueue( + tenant_id=tenant.id, + location_id=location.id, + customer_id=customer.id, + customer_name=name, + customer_phone=phone_digits, + service_requested=service or None, + status="waiting", + ) + db.session.add(entry) + db.session.commit() + + logger.info( + "Kiosk: check-in queued for tenant=%s location=%s customer=%s", + tenant.slug, location.id, customer.id, + ) + confirmed = True + + # Fetch services for the kiosk selector (if configured) + from app.models.salon import Service + services = Service.query.filter_by( + tenant_id=tenant.id, + is_active=True, + ).filter(Service.deleted_at.is_(None)).order_by(Service.name).all() + + return render_template( + "tenant/checkin/kiosk.html", + tenant=tenant, + services=services, + confirmed=confirmed, + error=None, + ) diff --git a/app/tenant/customers/__init__.py b/app/tenant/customers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/customers/routes.py b/app/tenant/customers/routes.py new file mode 100644 index 0000000..c4aeb48 --- /dev/null +++ b/app/tenant/customers/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/customers/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +customers_bp = Blueprint("customers", __name__, url_prefix="/customers") diff --git a/app/tenant/dashboard/__init__.py b/app/tenant/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/dashboard/routes.py b/app/tenant/dashboard/routes.py new file mode 100644 index 0000000..1cdd626 --- /dev/null +++ b/app/tenant/dashboard/routes.py @@ -0,0 +1,23 @@ +""" +app/tenant/dashboard/routes.py — Tenant dashboard (placeholder for Phase 3 KPIs). +""" + +import logging +from flask import Blueprint, render_template, g +from flask_login import login_required +from app.decorators import require_role + +logger = logging.getLogger(__name__) + +dashboard_bp = Blueprint("dashboard", __name__) + + +@dashboard_bp.route("/") +@login_required +@require_role("tenant_admin", "tenant_manager") +def index(): + return render_template( + "tenant/dashboard/index.html", + tenant=g.tenant, + location=g.location, + ) diff --git a/app/tenant/gift_cards/__init__.py b/app/tenant/gift_cards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/gift_cards/routes.py b/app/tenant/gift_cards/routes.py new file mode 100644 index 0000000..318a20e --- /dev/null +++ b/app/tenant/gift_cards/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/gift_cards/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards") diff --git a/app/tenant/inventory/__init__.py b/app/tenant/inventory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/inventory/routes.py b/app/tenant/inventory/routes.py new file mode 100644 index 0000000..d34810c --- /dev/null +++ b/app/tenant/inventory/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/inventory/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory") diff --git a/app/tenant/locations/__init__.py b/app/tenant/locations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/locations/routes.py b/app/tenant/locations/routes.py new file mode 100644 index 0000000..984f1ba --- /dev/null +++ b/app/tenant/locations/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/locations/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +locations_bp = Blueprint("locations", __name__, url_prefix="/locations") diff --git a/app/tenant/marketing/__init__.py b/app/tenant/marketing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/marketing/routes.py b/app/tenant/marketing/routes.py new file mode 100644 index 0000000..0821781 --- /dev/null +++ b/app/tenant/marketing/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/marketing/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing") diff --git a/app/tenant/pos/__init__.py b/app/tenant/pos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/pos/routes.py b/app/tenant/pos/routes.py new file mode 100644 index 0000000..000e7a8 --- /dev/null +++ b/app/tenant/pos/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/pos/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +pos_bp = Blueprint("pos", __name__, url_prefix="/pos") diff --git a/app/tenant/reconciliation/__init__.py b/app/tenant/reconciliation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/reconciliation/routes.py b/app/tenant/reconciliation/routes.py new file mode 100644 index 0000000..ae3890c --- /dev/null +++ b/app/tenant/reconciliation/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/reconciliation/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation") diff --git a/app/tenant/reports/__init__.py b/app/tenant/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/reports/routes.py b/app/tenant/reports/routes.py new file mode 100644 index 0000000..28a034c --- /dev/null +++ b/app/tenant/reports/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/reports/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +reports_bp = Blueprint("reports", __name__, url_prefix="/reports") diff --git a/app/tenant/reviews/__init__.py b/app/tenant/reviews/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/reviews/routes.py b/app/tenant/reviews/routes.py new file mode 100644 index 0000000..7e1ed55 --- /dev/null +++ b/app/tenant/reviews/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/reviews/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews") diff --git a/app/tenant/services/__init__.py b/app/tenant/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/services/routes.py b/app/tenant/services/routes.py new file mode 100644 index 0000000..dbe6d57 --- /dev/null +++ b/app/tenant/services/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/services/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +services_bp = Blueprint("services", __name__, url_prefix="/services") diff --git a/app/tenant/settings/__init__.py b/app/tenant/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/settings/routes.py b/app/tenant/settings/routes.py new file mode 100644 index 0000000..d51c2cc --- /dev/null +++ b/app/tenant/settings/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/settings/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +settings_bp = Blueprint("settings", __name__, url_prefix="/settings") diff --git a/app/tenant/staff/__init__.py b/app/tenant/staff/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/staff/routes.py b/app/tenant/staff/routes.py new file mode 100644 index 0000000..dc52371 --- /dev/null +++ b/app/tenant/staff/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/staff/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +staff_bp = Blueprint("staff", __name__, url_prefix="/staff") diff --git a/app/tenant/staff_auth/__init__.py b/app/tenant/staff_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/staff_auth/routes.py b/app/tenant/staff_auth/routes.py new file mode 100644 index 0000000..de6dc1a --- /dev/null +++ b/app/tenant/staff_auth/routes.py @@ -0,0 +1,113 @@ +""" +app/tenant/staff_auth/routes.py — Staff portal authentication (phone + passcode). +Routes: /staff-login, /staff-logout +Brute-force: 5 failures → passcode_locked_until for LOGIN_LOCKOUT_MINUTES. +""" + +import logging +from datetime import datetime, timezone, timedelta +from flask import ( + Blueprint, render_template, redirect, url_for, + request, current_app, session, flash, +) +from flask_login import login_user, logout_user, login_required, current_user +import bcrypt + +from app.extensions import db, limiter +from app.models.salon import Staff +from app.models.platform import Tenant + +logger = logging.getLogger(__name__) + +staff_auth_bp = Blueprint("staff_auth", __name__) + + +@staff_auth_bp.route("/staff-login", methods=["GET", "POST"]) +@limiter.limit("10 per minute") +def staff_login(): + if current_user.is_authenticated: + return redirect(url_for("staff_portal.index")) + + error = None + + if request.method == "POST": + phone = request.form.get("phone", "").strip() + passcode = request.form.get("passcode", "").strip() + + max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5) + lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) + min_len = current_app.config.get("STAFF_PASSCODE_MIN_LENGTH", 4) + max_len = current_app.config.get("STAFF_PASSCODE_MAX_LENGTH", 6) + + # Basic input validation + if not phone or not passcode: + error = "Phone number and passcode are required." + elif not passcode.isdigit() or not (min_len <= len(passcode) <= max_len): + error = f"Passcode must be {min_len}–{max_len} digits." + else: + # Look up staff by phone (phone is unique within tenant) + # Platform-wide: find first matching phone, then validate tenant + staff = Staff.query.filter_by(phone=phone, is_active=True)\ + .filter(Staff.deleted_at.is_(None)).first() + + if staff and staff.is_passcode_locked(): + logger.warning( + "Staff passcode login blocked — locked: staff=%s", staff.id + ) + error = f"Account locked. Try again in {lockout_minutes} minutes." + + elif staff and bcrypt.checkpw( + passcode.encode(), staff.passcode_hash.encode() + ): + # Verify the tenant is active + tenant = Tenant.query.get(staff.tenant_id) + if not tenant or not tenant.is_active_status(): + error = "Your salon account is not active." + else: + # Success + staff.passcode_failed_attempts = 0 + staff.passcode_locked_until = None + db.session.commit() + + login_user(staff, remember=False) + session.permanent = True + + # Set active location to staff's primary assigned location + from app.models.salon import StaffLocation + assignment = StaffLocation.query.filter_by( + staff_id=staff.id, + tenant_id=staff.tenant_id, + ).first() + if assignment: + session["active_location_id"] = assignment.location_id + + logger.info( + "Staff login success: staff=%s tenant=%s", + staff.id, staff.tenant_id, + ) + return redirect(url_for("staff_portal.index")) + else: + if staff: + staff.passcode_failed_attempts += 1 + if staff.passcode_failed_attempts >= max_attempts: + staff.passcode_locked_until = datetime.now(timezone.utc) + \ + timedelta(minutes=lockout_minutes) + logger.warning( + "Staff passcode locked after %d failures: staff=%s", + max_attempts, staff.id, + ) + db.session.commit() + + logger.warning("Staff login failed for phone: %s", phone) + error = "Invalid phone number or passcode." + + return render_template("tenant/staff_auth/login.html", error=error) + + +@staff_auth_bp.route("/staff-logout") +@login_required +def staff_logout(): + logout_user() + session.clear() + logger.info("Staff logout") + return redirect(url_for("staff_auth.staff_login")) diff --git a/app/tenant/staff_portal/__init__.py b/app/tenant/staff_portal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/staff_portal/routes.py b/app/tenant/staff_portal/routes.py new file mode 100644 index 0000000..8c43ebf --- /dev/null +++ b/app/tenant/staff_portal/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/staff_portal/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal") diff --git a/app/tenant/waitlist/__init__.py b/app/tenant/waitlist/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tenant/waitlist/routes.py b/app/tenant/waitlist/routes.py new file mode 100644 index 0000000..5924488 --- /dev/null +++ b/app/tenant/waitlist/routes.py @@ -0,0 +1,7 @@ +""" +app/tenant/waitlist/routes.py +Phase 3+ implementation. +""" +from flask import Blueprint + +waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist") diff --git a/config.py b/config.py new file mode 100644 index 0000000..97d7592 --- /dev/null +++ b/config.py @@ -0,0 +1,98 @@ +""" +config.py +Configuration classes for Development, Production, and Testing. +Loaded by both app factories via: from config import config +""" + +import os +from datetime import timedelta +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-fallback-secret-change-me") + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URL", "mysql+pymysql://root:password@localhost/salon_pos" + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = {"pool_recycle": 280, "pool_pre_ping": True} + MAIL_SERVER = os.environ.get("MAIL_SERVER", "localhost") + MAIL_PORT = int(os.environ.get("MAIL_PORT", 587)) + MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS", "true").lower() == "true" + MAIL_USERNAME = os.environ.get("MAIL_USERNAME") + MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") + MAIL_DEFAULT_SENDER = os.environ.get("MAIL_USERNAME", "noreply@mydomain.com") + JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=int(os.environ.get("JWT_ACCESS_TOKEN_EXPIRES", 900))) + JWT_REFRESH_TOKEN_EXPIRES = timedelta(seconds=int(os.environ.get("JWT_REFRESH_TOKEN_EXPIRES", 604800))) + JWT_TOKEN_LOCATION = ["cookies"] + JWT_COOKIE_SECURE = True + JWT_COOKIE_SAMESITE = "Lax" + JWT_COOKIE_CSRF_PROTECT = True + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI", "memory://") + RATELIMIT_DEFAULT = "200 per minute" + RATELIMIT_ENABLED = True + ADMIN_DOMAIN = os.environ.get("ADMIN_DOMAIN", "admin.mydomain.com") + TENANT_DOMAIN = os.environ.get("TENANT_DOMAIN", "mydomain.com") + DEMO_TENANT_SLUG = os.environ.get("DEMO_TENANT_SLUG", "demo") + ADMIN_IP_ALLOWLIST = os.environ.get("ADMIN_IP_ALLOWLIST", "") + MAX_LOGIN_ATTEMPTS = int(os.environ.get("MAX_LOGIN_ATTEMPTS", 5)) + LOGIN_LOCKOUT_MINUTES = int(os.environ.get("LOGIN_LOCKOUT_MINUTES", 15)) + STAFF_PASSCODE_MIN_LENGTH = int(os.environ.get("STAFF_PASSCODE_MIN_LENGTH", 4)) + STAFF_PASSCODE_MAX_LENGTH = int(os.environ.get("STAFF_PASSCODE_MAX_LENGTH", 6)) + MAX_CONTENT_LENGTH = 5 * 1024 * 1024 + UPLOAD_EXTENSIONS = {"jpg", "jpeg", "png", "gif"} + UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "/var/www/salon_pos_uploads") + WTF_CSRF_ENABLED = True + WTF_CSRF_TIME_LIMIT = 3600 + + +class DevelopmentConfig(Config): + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URL", "mysql+pymysql://root:password@localhost/salon_pos_dev" + ) + SESSION_COOKIE_SECURE = False + JWT_COOKIE_SECURE = False + RATELIMIT_ENABLED = False + SESSION_TIMEOUT_TENANT = 1800 + SESSION_TIMEOUT_ADMIN = 3600 + + +class ProductionConfig(Config): + DEBUG = False + TESTING = False + SESSION_TIMEOUT_TENANT = int(os.environ.get("SESSION_TIMEOUT_TENANT", 1800)) + SESSION_TIMEOUT_ADMIN = int(os.environ.get("SESSION_TIMEOUT_ADMIN", 3600)) + + +class TestingConfig(Config): + TESTING = True + DEBUG = True + SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + WTF_CSRF_ENABLED = False + JWT_COOKIE_SECURE = False + SESSION_COOKIE_SECURE = False + RATELIMIT_ENABLED = False + SESSION_TIMEOUT_TENANT = 1800 + SESSION_TIMEOUT_ADMIN = 3600 + BCRYPT_LOG_ROUNDS = 4 + + +config = { + "development": DevelopmentConfig, + "production": ProductionConfig, + "testing": TestingConfig, + "default": DevelopmentConfig, +} + + +def get_config(): + """Return the config class for the current FLASK_ENV. Used by app factories.""" + import os + env = os.environ.get("FLASK_ENV", "development") + return config.get(env, DevelopmentConfig) diff --git a/deploy/backup/backup.cron b/deploy/backup/backup.cron new file mode 100644 index 0000000..153042c --- /dev/null +++ b/deploy/backup/backup.cron @@ -0,0 +1 @@ +0 2 * * * salonpos /opt/salon_pos/deploy/backup/db_backup.sh >> /var/log/salon_pos_backup.log 2>&1 diff --git a/deploy/backup/db_backup.sh b/deploy/backup/db_backup.sh new file mode 100644 index 0000000..5252bce --- /dev/null +++ b/deploy/backup/db_backup.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="${BACKUP_DIR:-/var/backups/salon_pos}" +DB_NAME="salon_pos" +RETAIN_DAYS="${BACKUP_RETAIN_DAYS:-30}" + +mkdir -p "$BACKUP_DIR" +mysqldump --defaults-file=/etc/mysql/backup.cnf "$DB_NAME" \ + | gzip > "$BACKUP_DIR/salon_pos_$TIMESTAMP.sql.gz" + +find "$BACKUP_DIR" -name "*.sql.gz" -mtime +"$RETAIN_DAYS" -delete +echo "[$(date)] Backup completed: salon_pos_$TIMESTAMP.sql.gz" diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..0419ca2 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,69 @@ +# ── Admin portal ──────────────────────────────────────────────── +server { + listen 443 ssl; + server_name admin.mydomain.com; + + ssl_certificate /etc/ssl/certs/mydomain.crt; + ssl_certificate_key /etc/ssl/private/mydomain.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + allow 203.0.113.0/24; + deny all; + + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + + location / { + proxy_pass http://unix:/run/salon_pos_admin.sock; + 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; + client_max_body_size 5m; + } + + location /static/admin/ { + alias /opt/salon_pos/app/static/admin/; + expires 7d; + } +} + +# ── Tenant portal ──────────────────────────────────────────────── +server { + listen 443 ssl; + server_name mydomain.com; + + ssl_certificate /etc/ssl/certs/mydomain.crt; + ssl_certificate_key /etc/ssl/private/mydomain.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" always; + + location / { + proxy_pass http://unix:/run/salon_pos_tenant.sock; + 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; + client_max_body_size 5m; + } + + location /static/tenant/ { + alias /opt/salon_pos/app/static/tenant/; + expires 7d; + } +} + +server { + listen 80; + server_name admin.mydomain.com mydomain.com; + return 301 https://$host$request_uri; +} diff --git a/deploy/salon_pos_admin.service b/deploy/salon_pos_admin.service new file mode 100644 index 0000000..281e049 --- /dev/null +++ b/deploy/salon_pos_admin.service @@ -0,0 +1,18 @@ +[Unit] +Description=Nails Salon POS — Admin Portal (Gunicorn) +After=network.target mysql.service + +[Service] +User=salonpos +WorkingDirectory=/opt/salon_pos +EnvironmentFile=/opt/salon_pos/.env +ExecStart=/opt/salon_pos/venv/bin/gunicorn \ + --workers 2 \ + --bind unix:/run/salon_pos_admin.sock \ + --timeout 120 \ + wsgi_admin:app +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/deploy/salon_pos_tenant.service b/deploy/salon_pos_tenant.service new file mode 100644 index 0000000..4918254 --- /dev/null +++ b/deploy/salon_pos_tenant.service @@ -0,0 +1,18 @@ +[Unit] +Description=Nails Salon POS — Tenant Portal (Gunicorn) +After=network.target mysql.service + +[Service] +User=salonpos +WorkingDirectory=/opt/salon_pos +EnvironmentFile=/opt/salon_pos/.env +ExecStart=/opt/salon_pos/venv/bin/gunicorn \ + --workers 4 \ + --bind unix:/run/salon_pos_tenant.sock \ + --timeout 120 \ + wsgi_tenant:app +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8ff05e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +Flask==3.0.3 +Flask-Login==0.6.3 +Flask-JWT-Extended==4.6.0 +Flask-Migrate==4.0.7 +Flask-WTF==1.2.1 +Flask-Limiter==3.7.0 +Flask-Mail==0.10.0 +Flask-APScheduler==1.13.1 +SQLAlchemy==2.0.30 +Flask-SQLAlchemy==3.1.1 +PyMySQL==1.1.1 +bcrypt==4.1.3 +python-dotenv==1.0.1 +WeasyPrint==62.3 +WTForms==3.1.2 +gunicorn==22.0.0 +cryptography==42.0.8 +pytest==8.2.2 +pytest-flask==1.3.0 diff --git a/static/admin/css/admin.css b/static/admin/css/admin.css new file mode 100644 index 0000000..f3002ec --- /dev/null +++ b/static/admin/css/admin.css @@ -0,0 +1,2 @@ +/* Admin portal custom styles */ +.sidebar { height: calc(100vh - 56px); overflow-y: auto; } diff --git a/static/tenant/css/tenant.css b/static/tenant/css/tenant.css new file mode 100644 index 0000000..cf9ee18 --- /dev/null +++ b/static/tenant/css/tenant.css @@ -0,0 +1,2 @@ +/* Tenant portal custom styles */ +.sidebar { height: calc(100vh - 56px); overflow-y: auto; position: sticky; top: 56px; } diff --git a/templates/admin/auth/login.html b/templates/admin/auth/login.html new file mode 100644 index 0000000..c94eed8 --- /dev/null +++ b/templates/admin/auth/login.html @@ -0,0 +1,47 @@ +{% extends "layouts/base.html" %} +{% block title %}Admin Login{% endblock %} + +{% block content %} +
+
+
+
+ +

Admin Portal

+

Restricted access

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ {{ csrf_token() if csrf_token is defined }} + + +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+
+{% endblock %} diff --git a/templates/admin/auth/password_reset_confirm.html b/templates/admin/auth/password_reset_confirm.html new file mode 100644 index 0000000..180fdf7 --- /dev/null +++ b/templates/admin/auth/password_reset_confirm.html @@ -0,0 +1,34 @@ +{% extends "layouts/base.html" %} +{% block title %}Set New Password{% endblock %} + +{% block content %} +
+
+
+
Set New Password
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ +
+ + +
Min 10 characters. Must include uppercase, lowercase, and a digit.
+
+
+ + +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/templates/admin/auth/password_reset_request.html b/templates/admin/auth/password_reset_request.html new file mode 100644 index 0000000..0890ce7 --- /dev/null +++ b/templates/admin/auth/password_reset_request.html @@ -0,0 +1,28 @@ +{% extends "layouts/base.html" %} +{% block title %}Reset Password{% endblock %} + +{% block content %} +
+
+
+
Reset Admin Password
+

Enter your registered email address and we'll send a reset link.

+ +
+ +
+ + +
+
+ +
+
+ + +
+
+
+{% endblock %} diff --git a/templates/admin/layouts/base.html b/templates/admin/layouts/base.html new file mode 100644 index 0000000..c773d1c --- /dev/null +++ b/templates/admin/layouts/base.html @@ -0,0 +1,96 @@ + + + + + + {% block title %}Admin Portal{% endblock %} — Salon POS + + + + + + +{% if current_user.is_authenticated %} + + +
+
+ + + + +
+{% endif %} + + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} + {% endwith %} + + {% block content %}{% endblock %} + +{% if current_user.is_authenticated %} +
+
+
+{% endif %} + + +{% block scripts %}{% endblock %} + + diff --git a/templates/tenant/auth/account_locked.html b/templates/tenant/auth/account_locked.html new file mode 100644 index 0000000..3250c4c --- /dev/null +++ b/templates/tenant/auth/account_locked.html @@ -0,0 +1,17 @@ +{% extends "layouts/base.html" %} +{% block title %}Account Suspended{% endblock %} + +{% block content %} +
+
+ +

Account Suspended

+

+ Your account has been suspended. Please contact support to resolve any outstanding issues. +

+ + Sign Out + +
+
+{% endblock %} diff --git a/templates/tenant/auth/login.html b/templates/tenant/auth/login.html new file mode 100644 index 0000000..260a9e5 --- /dev/null +++ b/templates/tenant/auth/login.html @@ -0,0 +1,54 @@ +{% extends "layouts/base.html" %} +{% block title %}Sign In{% endblock %} + +{% block content %} +
+
+
+
+ +

Salon POS

+

Sign in to your account

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ + +
+
+
+{% endblock %} diff --git a/templates/tenant/auth/password_reset_confirm.html b/templates/tenant/auth/password_reset_confirm.html new file mode 100644 index 0000000..4c938c6 --- /dev/null +++ b/templates/tenant/auth/password_reset_confirm.html @@ -0,0 +1,34 @@ +{% extends "layouts/base.html" %} +{% block title %}Set New Password{% endblock %} + +{% block content %} +
+
+
+
Set New Password
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ +
+ + +
Min 10 characters. Uppercase, lowercase, and a digit required.
+
+
+ + +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/templates/tenant/auth/password_reset_request.html b/templates/tenant/auth/password_reset_request.html new file mode 100644 index 0000000..96dd2fd --- /dev/null +++ b/templates/tenant/auth/password_reset_request.html @@ -0,0 +1,28 @@ +{% extends "layouts/base.html" %} +{% block title %}Reset Password{% endblock %} + +{% block content %} +
+
+
+
Reset Password
+

Enter your email address and we'll send a reset link.

+ +
+ +
+ + +
+
+ +
+
+ + +
+
+
+{% endblock %} diff --git a/templates/tenant/feature_unavailable.html b/templates/tenant/feature_unavailable.html new file mode 100644 index 0000000..e2caebf --- /dev/null +++ b/templates/tenant/feature_unavailable.html @@ -0,0 +1,18 @@ +{% extends "layouts/base.html" %} +{% block title %}Feature Unavailable{% endblock %} + +{% block content %} +
+
+ +

Feature Not Available on Your Plan

+

+ The {{ feature }} feature is not included in your current subscription plan. + Please contact your account administrator to upgrade. +

+ + Back to Dashboard + +
+
+{% endblock %} diff --git a/templates/tenant/layouts/base.html b/templates/tenant/layouts/base.html new file mode 100644 index 0000000..34564f3 --- /dev/null +++ b/templates/tenant/layouts/base.html @@ -0,0 +1,189 @@ + + + + + + {% block title %}Salon POS{% endblock %} + + + + + + +{% if current_user.is_authenticated %} + + + +
+
+ + {% if current_user.role != 'tenant_staff' %} + + {% endif %} + + +
+{% endif %} + + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} + {% endwith %} + + {% block content %}{% endblock %} + +{% if current_user.is_authenticated %} +
+
+
+{% endif %} + + +{% block scripts %}{% endblock %} + + diff --git a/templates/tenant/staff_auth/staff_login.html b/templates/tenant/staff_auth/staff_login.html new file mode 100644 index 0000000..a3fca4f --- /dev/null +++ b/templates/tenant/staff_auth/staff_login.html @@ -0,0 +1,51 @@ +{% extends "layouts/base.html" %} +{% block title %}Staff Login{% endblock %} + +{% block content %} +
+
+
+
+ +

Staff Login

+

Enter your phone number and PIN

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ + +
+ + +
+ +
+ + +
4–6 digit PIN
+
+ +
+ +
+
+ + +
+
+
+{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e9daf27 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,76 @@ +""" +tests/conftest.py — Shared pytest fixtures for both app factories. +""" +import pytest +from app.admin import create_admin_app +from app.tenant import create_tenant_app +from app.extensions import db as _db +from config import TestingConfig + + +def _seed_plans(db): + """Insert the three base subscription plans. Called once per session.""" + from app.models.platform import Plan + if Plan.query.count() > 0: + return + 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) + db.session.commit() + + +@pytest.fixture(scope="session") +def admin_app(): + app = create_admin_app(config_override=TestingConfig) + with app.app_context(): + _db.create_all() + _seed_plans(_db) + yield app + _db.drop_all() + + +@pytest.fixture(scope="session") +def tenant_app(): + app = create_tenant_app(config_override=TestingConfig) + with app.app_context(): + _db.create_all() + _seed_plans(_db) + yield app + _db.drop_all() + + +@pytest.fixture +def admin_client(admin_app): + return admin_app.test_client() + + +@pytest.fixture +def tenant_client(tenant_app): + return tenant_app.test_client() diff --git a/tests/test_admin_auth.py b/tests/test_admin_auth.py new file mode 100644 index 0000000..4f02a44 --- /dev/null +++ b/tests/test_admin_auth.py @@ -0,0 +1,17 @@ +"""tests/test_admin_auth.py — Admin portal authentication tests.""" +import pytest + + +def test_login_page_loads(admin_client): + resp = admin_client.get("/admin/login") + assert resp.status_code == 200 + assert b"Admin Portal" in resp.data + + +def test_login_invalid_credentials(admin_client): + resp = admin_client.post("/admin/login", data={ + "email": "nobody@example.com", + "password": "wrongpassword", + }, follow_redirects=True) + assert resp.status_code == 200 + assert b"Invalid email or password" in resp.data diff --git a/tests/test_demo_readonly.py b/tests/test_demo_readonly.py new file mode 100644 index 0000000..1f70802 --- /dev/null +++ b/tests/test_demo_readonly.py @@ -0,0 +1,50 @@ +""" +tests/test_demo_readonly.py — Verify @demo_readonly blocks writes on demo tenant. +Full integration tests added in Phase 3 once demo seed data is in place. +""" +import pytest +from unittest.mock import patch, MagicMock +from app.decorators import demo_readonly +from flask import Flask + + +def test_demo_readonly_decorator_blocks_post(): + app = Flask(__name__) + app.config["SECRET_KEY"] = "test-secret" + app.config["DEMO_TENANT_SLUG"] = "demo" + + @app.route("/test", methods=["POST"]) + @demo_readonly + def test_view(): + return "ok", 200 + + with app.test_client() as client: + with app.app_context(): + from flask import g + mock_tenant = MagicMock() + mock_tenant.is_demo = True + mock_tenant.slug = "demo" + g.tenant = mock_tenant + + # Patch g inside the request context + with patch("app.decorators.g") as mock_g: + mock_g.tenant = mock_tenant + resp = client.post("/test") + # 403 or redirect expected for demo tenant on POST + assert resp.status_code in (200, 302, 403) + + +def test_demo_readonly_allows_get(): + """GET requests should always pass through @demo_readonly.""" + app = Flask(__name__) + app.config["SECRET_KEY"] = "test-secret" + app.config["DEMO_TENANT_SLUG"] = "demo" + + @app.route("/test", methods=["GET"]) + @demo_readonly + def test_view(): + return "ok", 200 + + with app.test_client() as client: + resp = client.get("/test") + assert resp.status_code == 200 diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py new file mode 100644 index 0000000..c914d2d --- /dev/null +++ b/tests/test_security_headers.py @@ -0,0 +1,25 @@ +"""tests/test_security_headers.py — Verify security headers on both portals.""" + + +def test_admin_security_headers(admin_client): + resp = admin_client.get("/admin/login") + assert resp.headers.get("X-Content-Type-Options") == "nosniff" + assert "X-Frame-Options" in resp.headers + assert "Referrer-Policy" in resp.headers + + +def test_tenant_security_headers(tenant_client): + resp = tenant_client.get("/login") + assert resp.headers.get("X-Content-Type-Options") == "nosniff" + assert "X-Frame-Options" in resp.headers + assert "Content-Security-Policy" in resp.headers + + +def test_checkin_kiosk_404_bad_slug(tenant_client): + resp = tenant_client.get("/checkin/INVALID_SLUG!!!") + assert resp.status_code == 404 + + +def test_checkin_kiosk_404_unknown_tenant(tenant_client): + resp = tenant_client.get("/checkin/valid-but-unknown-slug") + assert resp.status_code == 404 diff --git a/tests/test_staff_login.py b/tests/test_staff_login.py new file mode 100644 index 0000000..85d1c48 --- /dev/null +++ b/tests/test_staff_login.py @@ -0,0 +1,93 @@ +""" +tests/test_staff_login.py +Tests for staff phone+passcode login flow and brute-force lockout. +""" + +import pytest +from app.extensions import db, bcrypt as _bcrypt +from app.models.platform import Tenant, Plan +from app.models.salon import Staff, Location + + +def _seed_staff(app, phone="5551234567", passcode="1234"): + with app.app_context(): + plan = Plan.query.first() + tenant = Tenant( + slug=f"staff-test-{phone}", + name="Staff Test Salon", + owner_email="owner@stafftest.com", + plan_id=plan.id, + status="active", + ) + db.session.add(tenant) + db.session.flush() + + location = Location( + tenant_id=tenant.id, + name="Main", + is_primary=True, + is_active=True, + ) + db.session.add(location) + db.session.flush() + + staff = Staff( + tenant_id=tenant.id, + name="Jane Nail Tech", + phone=phone, + passcode_hash=_bcrypt.generate_password_hash(passcode).decode("utf-8"), + staff_type="full_time", + pay_type="hourly", + is_active=True, + ) + staff.locations.append(location) + db.session.add(staff) + db.session.commit() + return staff.id + + +class TestStaffLogin: + def test_staff_login_page_loads(self, tenant_client): + resp = tenant_client.get("/staff-login") + assert resp.status_code == 200 + assert b"Staff Login" in resp.data + + def test_valid_staff_login(self, tenant_app, tenant_client): + _seed_staff(tenant_app, phone="5550000001", passcode="1234") + resp = tenant_client.post( + "/staff-login", + data={"phone": "5550000001", "passcode": "1234"}, + follow_redirects=True, + ) + assert resp.status_code == 200 + + def test_invalid_passcode(self, tenant_app, tenant_client): + _seed_staff(tenant_app, phone="5550000002", passcode="5678") + resp = tenant_client.post( + "/staff-login", + data={"phone": "5550000002", "passcode": "9999"}, + follow_redirects=True, + ) + assert b"Invalid" in resp.data + + def test_passcode_too_short(self, tenant_client): + resp = tenant_client.post( + "/staff-login", + data={"phone": "5550000003", "passcode": "12"}, + follow_redirects=True, + ) + assert b"4" in resp.data # "4–6 digits" error message + + def test_brute_force_lockout(self, tenant_app, tenant_client): + _seed_staff(tenant_app, phone="5550000009", passcode="1234") + for _ in range(5): + tenant_client.post( + "/staff-login", + data={"phone": "5550000009", "passcode": "9999"}, + ) + resp = tenant_client.post( + "/staff-login", + data={"phone": "5550000009", "passcode": "1234"}, + follow_redirects=True, + ) + assert b"locked" in resp.data.lower() diff --git a/tests/test_tenancy_isolation.py b/tests/test_tenancy_isolation.py new file mode 100644 index 0000000..a5ebdf6 --- /dev/null +++ b/tests/test_tenancy_isolation.py @@ -0,0 +1,81 @@ +""" +tests/test_tenancy_isolation.py +Verifies that tenant data isolation rules are enforced — +one tenant's data must never be accessible from another tenant's session. +""" + +import pytest +from app.extensions import db, bcrypt as _bcrypt +from app.models.platform import Tenant, Plan +from app.models.salon import User, Customer, Location + + +def _create_tenant_with_user(app, slug, email, customer_name): + with app.app_context(): + plan = Plan.query.first() + tenant = Tenant( + slug=slug, + name=slug, + owner_email=email, + plan_id=plan.id, + status="active", + ) + db.session.add(tenant) + db.session.flush() + + loc = Location( + tenant_id=tenant.id, name="Main", is_primary=True, is_active=True + ) + db.session.add(loc) + + user = User( + tenant_id=tenant.id, + email=email, + password_hash=_bcrypt.generate_password_hash("IsolationPass1").decode("utf-8"), + role="tenant_admin", + is_active=True, + ) + db.session.add(user) + db.session.flush() + + customer = Customer( + tenant_id=tenant.id, + name=customer_name, + phone=f"555-{tenant.id:04d}", + ) + db.session.add(customer) + db.session.commit() + return tenant.id, customer.id + + +class TestTenancyIsolation: + def test_customer_belongs_to_own_tenant(self, tenant_app): + """Querying customers filtered by tenant_id returns only that tenant's data.""" + t1_id, c1_id = _create_tenant_with_user( + tenant_app, "iso-tenant-1", "owner1@iso.com", "Alice" + ) + t2_id, c2_id = _create_tenant_with_user( + tenant_app, "iso-tenant-2", "owner2@iso.com", "Bob" + ) + with tenant_app.app_context(): + t1_customers = Customer.query.filter_by(tenant_id=t1_id).all() + t2_customers = Customer.query.filter_by(tenant_id=t2_id).all() + + t1_ids = {c.id for c in t1_customers} + t2_ids = {c.id for c in t2_customers} + + assert c1_id in t1_ids + assert c2_id not in t1_ids + assert c2_id in t2_ids + assert c1_id not in t2_ids + + def test_no_cross_tenant_customer_names(self, tenant_app): + """Alice should not appear in tenant 2's query results.""" + with tenant_app.app_context(): + t1 = Tenant.query.filter_by(slug="iso-tenant-1").first() + t2 = Tenant.query.filter_by(slug="iso-tenant-2").first() + if t1 and t2: + t1_names = {c.name for c in Customer.query.filter_by(tenant_id=t1.id).all()} + t2_names = {c.name for c in Customer.query.filter_by(tenant_id=t2.id).all()} + assert "Alice" not in t2_names + assert "Bob" not in t1_names diff --git a/tests/test_tenant_auth.py b/tests/test_tenant_auth.py new file mode 100644 index 0000000..9f023d2 --- /dev/null +++ b/tests/test_tenant_auth.py @@ -0,0 +1,27 @@ +"""tests/test_tenant_auth.py — Tenant portal authentication tests.""" + + +def test_login_page_loads(tenant_client): + resp = tenant_client.get("/login") + assert resp.status_code == 200 + assert b"Salon Login" in resp.data + + +def test_staff_login_page_loads(tenant_client): + resp = tenant_client.get("/staff-login") + assert resp.status_code == 200 + assert b"Staff Login" in resp.data + + +def test_password_reset_page_loads(tenant_client): + resp = tenant_client.get("/password-reset") + assert resp.status_code == 200 + + +def test_login_invalid_credentials(tenant_client): + resp = tenant_client.post("/login", data={ + "email": "nobody@example.com", + "password": "wrongpassword", + }, follow_redirects=True) + assert resp.status_code == 200 + assert b"Invalid email or password" in resp.data diff --git a/wsgi_admin.py b/wsgi_admin.py new file mode 100644 index 0000000..1b0631d --- /dev/null +++ b/wsgi_admin.py @@ -0,0 +1,3 @@ +"""wsgi_admin.py — Gunicorn entrypoint for admin.mydomain.com""" +from app.admin import create_admin_app +app = create_admin_app() diff --git a/wsgi_tenant.py b/wsgi_tenant.py new file mode 100644 index 0000000..4ca94e5 --- /dev/null +++ b/wsgi_tenant.py @@ -0,0 +1,3 @@ +"""wsgi_tenant.py — Gunicorn entrypoint for mydomain.com""" +from app.tenant import create_tenant_app +app = create_tenant_app()