63 KiB
CLAUDE.md — Nails Salon POS (Multi-Tenant SaaS)
Project Overview
A multi-tenant SaaS POS web application for nail salons. The platform is split across two dedicated domains:
| Domain | Audience | Purpose |
|---|---|---|
posadmin.ngodanguyen.tech |
System Admins | Platform management: system users, tenants, plan enforcement, settings override |
pos.ngodanguyen.tech |
Tenant users | Salon management: staff, customers, bookings, POS, inventory, marketing, reports |
A System Admin manages the entire platform and can override any tenant's settings. Each Tenant (salon owner/manager/staff) operates exclusively within their own salon context, accessed via mydomain.com/login with tenant resolved from their credentials.
Tech Stack
| Layer | Technology |
|---|---|
| Backend framework | Python 3.11+ / Flask |
| WSGI server | Gunicorn |
| Database | MySQL 8.0+ |
| ORM | SQLAlchemy + Flask-Migrate (Alembic) |
| Auth (UI) | Flask-Login + bcrypt |
| Auth (API) | JWT via Flask-JWT-Extended (httpOnly cookie transport) |
| Reverse proxy | Nginx |
| Process manager | systemd |
| Frontend | Jinja2 + Bootstrap 5 + vanilla JS |
| PDF/receipts | WeasyPrint |
| Scheduler | APScheduler (subscription reminders, reports, campaign sends) |
| Forms & CSRF | Flask-WTF |
| Rate limiting | Flask-Limiter (Redis or in-memory backend) |
| Input validation | WTForms validators + custom sanitisers |
| Environment config | python-dotenv |
Domain Architecture
Domain 1 — posadmin.ngodanguyen.tech (System Admin Portal)
Served by a dedicated Nginx server block. Access is restricted to users with the superadmin role. An IP allowlist at the Nginx level provides an additional layer of protection.
Functionalities:
- System user management — create, edit, deactivate superadmin accounts; role assignment; force password reset
- Tenant management — create, view, edit, suspend, cancel tenants; assign/change plans; view billing history; manual invoice entry
- Tenant settings override — view and override any tenant's salon settings (business name, hours, feature flags, commission rates, etc.) with a visible "override active" indicator; lift overrides individually or in bulk
- Plan management — create and edit subscription plans, feature flags, pricing, max staff/locations limits
- Audit log — immutable, append-only log of all superadmin actions (actor, action, target, timestamp, before/after values); 1-year retention
- Platform analytics — active tenants, MRR, trial conversions, churn rate, revenue trends
Domain 2 — pos.ngodanguyen.tech (Tenant Portal)
Served by a separate Nginx server block. All tenants share this single domain (pos.ngodanguyen.tech). The active tenant and location are resolved from the authenticated session.
mydomain.com exposes two distinct login flows:
| URL | Credentials | Who uses it |
|---|---|---|
pos.ngodanguyen.tech/login |
Email + password | tenant_admin, tenant_manager |
pos.ngodanguyen.tech/staff-login |
Phone number + passcode (4–6 digit PIN) | tenant_staff — kiosk-style login |
pos.ngodanguyen.tech/checkin/{tenant_slug} |
No credentials — public kiosk page | Customer self check-in on iPad |
Staff login is designed for quick front-of-house use on a shared tablet. After authenticating, staff land on their personal Staff Portal — a simplified view scoped exclusively to their own data.
The Customer Check-In Kiosk (mydomain.com/checkin/{tenant_slug}) is a separate public-facing page with no authentication. It is designed to run fullscreen on a dedicated iPad at the front desk. The page auto-resets after each submission for the next customer.
Functionalities (scoped to the authenticated user's tenant and active location):
- Dashboard — KPIs scoped to active location: daily revenue, appointment count, staff on-shift, low-stock alerts, recent transactions, average review rating (last 30 days), today’s tips total
- Location management (tenant_admin only) — create/edit/deactivate locations; set primary location; per-location settings (hours, timezone, contact info)
- Location switcher — persistent in nav bar; accessible to
tenant_adminandtenant_manager;tenant_staffis locked to their assigned location(s) - Staff management — profiles, job type (salon manager, full-time, part-time, seasonal, receptionist), system role, location assignments, pay structure (hourly rate / salary / guarantee pay), commission rate, schedules per location, active/inactive toggle; passcode set by
tenant_adminat staff creation time; passcode reset bytenant_adminortenant_manager - Staff Portal (accessed via
mydomain.com/staff-login— phone number + passcode only) — personal self-service view fortenant_staff:- Today’s appointment schedule (own bookings only, current location)
- Upcoming appointments (next 7 days)
- Working hours log (clock-in / clock-out per shift at active location)
- Pay summary (current period: base pay calculated from pay type + hours or salary period, commission earned, guarantee top-up if applicable)
- Commission summary (per-transaction breakdown)
- Payment history (transactions they personally served)
- Read-only personal profile (name, phone, assigned locations, schedule)
- 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 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 aspending; 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
- POS / Checkout — service + product selection; tip amount field (cash or app); automatic promotion detection at checkout (active promotions applied instantly — no manual coupon entry required); original price, discount percentage, and discounted price shown clearly per line item; gift card redemption as payment method; manual additional discount still available for
tenant_adminandtenant_manager; cash or app-based payment (Zelle, Venmo, CashApp, Other);payment_referencefield for app transaction IDs; receipt generation (print/PDF) showing promotional savings, tip, and gift card balance; option to email digital receipt to customer on file; void transaction with reason; Card/Stripe deferred to a future phase - Next-Visit Scheduling at Checkout — after payment is confirmed and before the receipt is printed, the receptionist is prompted with an optional “Schedule next visit?” step; they can book the customer’s next appointment directly from the checkout screen (same service, same staff, new date/time); the new appointment is created as
pending(orconfirmedif auto-confirm is enabled); the next visit date is printed on the current receipt and shown in the confirmation email; a 24-hour reminder is automatically scheduled for the rebooked appointment; if the customer declines, the step is skipped and the receipt proceeds normally;rebook_sourcefield onappointmentstable records that the booking originated from a checkout rebook - Tip Tracking — tip amount field at POS checkout (cash or app, same payment methods as transaction); tip attributed to the serving staff member; stored on
transactions.tip_amount; visible in Staff Portal (own tips per transaction); factored into pay period summary and commission reports; owner-level tip report by staff and period - Gift Cards —
tenant_adminissues gift cards with a set monetary value; unique code generated per card; at POS staff redeems code as a payment method (partial or full payment); balance checked and decremented on each use; remaining balance shown on receipt;gift_cardstable tracks code, original value, remaining balance, issuer, and expiry date - Inventory — per-location stock levels; reorder alerts; manual stock adjustment with reason log; automatic deduction on POS sale
- End-of-Day Reconciliation — “Close Day” action summarises total cash received, total app payments, total tips, expected cash in drawer; manager enters actual cash counted; system calculates and flags variance; stored in
daily_reconciliationstable; variance report visible totenant_admin; prevents undetected cash discrepancies - Marketing (Pro plan) — email campaigns to customer segments (audience filter by visit date, loyalty tier, service type); subject + body composer; scheduled or immediate send; basic delivery tracking (sent count, open count). SMS deferred to a future phase.
- Appointment Reminders — automated email reminder sent 24 hours before appointment (configurable: on/off and lead time per tenant); optional 2-hour reminder; reduces no-show rate; SMS version deferred to future phase
- Customer Reviews & Reputation — review request email sent automatically X minutes after checkout (default 60 min, configurable per tenant); customer rates the visit 1–5 stars with optional comment; if rating ≥ 4 stars, a follow-up screen/email suggests leaving a public review on Google, Facebook, or Yelp (platform review URLs configured by owner in salon settings); if rating < 4 stars, private thank-you message shown and feedback goes silently to the owner’s dashboard for internal improvement; one review request per transaction; no-show and voided transactions excluded from review sends
- Reports — revenue (daily/weekly/monthly, by staff, by service, by location); tip reports (by staff, by period); commission summary; staff pay period reports (base pay, commission, guarantee top-up, total per staff); no-show and cancellation metrics (by customer, by staff); rebook rate (percentage of checkouts where a next visit was scheduled); check-in kiosk usage (walk-ins per day via kiosk vs manual entry); promotion performance; review analytics (average rating, response rate, rating distribution, platform click-through); end-of-day reconciliation variance history; inventory valuation and low-stock; all exportable as CSV and PDF
- Salon settings — business info, operating hours, notification preferences, receipt footer, logo upload; review platform URLs (Google, Facebook, Yelp); appointment reminder timing; review request delay; auto-confirm booking toggle; gift card expiry policy; kiosk check-in settings (enable/disable kiosk page, service list shown on kiosk, auto-reset timer duration)
Settings override: Fields locked by a superadmin override display an "Override by admin" badge. The tenant cannot modify those fields until the override is lifted from the admin portal.
Multi-Tenancy Architecture
Strategy: Shared Database, Shared Schema (Row-Level Isolation)
Every tenant-owned table carries a tenant_id foreign key. All queries are automatically scoped via a Flask g.tenant context object set after authentication.
Tenant & Location Resolution
Email + password flow (mydomain.com/login) — for tenant_admin and tenant_manager:
- User submits email + password.
- Flask-Login loads the
Userrecord;user.tenant_idstored in session. @before_requesthook callsload_tenant_context()→ setsg.tenant; checkstenant.status.load_location_context()resolvesg.locationfrom session (last selected) or defaults to primary location.- All downstream queries filter by
g.tenant.idandg.location.id. - If
tenant.statusissuspendedorcancelled, user is redirected to a locked page.
Phone + passcode flow (mydomain.com/staff-login) — for tenant_staff:
- Staff submits phone number + 4–6 digit passcode.
- Flask looks up the
Staffrecord by(phone, tenant_id);tenant_idis resolved from the phone number's owning tenant (phone is unique platform-wide within a tenant, not globally — lookup is by phone first, then tenant validated via the staff record). - Passcode verified against
staff.passcode_hash(bcrypt). - Brute-force lockout: 5 failures →
passcode_locked_untilset for 15 minutes. - On success, a Flask-Login session is created scoped to the matched tenant and the staff's primary assigned location.
- Staff are redirected to
/staff/portal— the simplified personal dashboard. tenant_staffsessions cannot access any owner/manager routes;@require_roleenforces this.
On admin.mydomain.com, there is no g.tenant — all queries are unscoped platform-level queries.
Data Isolation Rules
- Every tenant-owned model MUST have
tenant_id(FK →tenants.id, non-nullable, indexed). - Location-specific models MUST also carry
location_id(FK →locations.id, indexed). - All blueprint query helpers MUST filter by
g.tenant.id— never rawModel.query.all(). - Location-scoped queries additionally filter by
g.location.id. tenant_stafflocation access is enforced by checkingstaff_locationsmembership beforeg.locationis set.- Superadmin routes bypass tenant scoping entirely.
- Cross-tenant data access is only permitted from the superadmin context.
- Settings overrides in
tenant_setting_overridestake precedence overtenant_settingsat the application layer. - Demo tenant is write-protected at the application layer: any
POST/PUT/DELETErequest on a demo session returns 403 with a "Demo account is read-only" message.
Database Schema (High-Level)
Platform-Level Tables (superadmin scope)
system_users
id, email, password_hash, name, role, is_active,
failed_login_attempts, locked_until,
password_reset_token, password_reset_expires_at,
last_login_at, created_at
plans
id, name, price_monthly, max_staff, max_locations,
features_json, is_active
tenants
id, slug, name, owner_email, plan_id, status,
trial_ends_at, subscription_expires_at,
is_demo, created_at, updated_at
tenant_billing_history
id, tenant_id, amount, description, paid_at, invoice_ref, recorded_by
tenant_setting_overrides
id, tenant_id, setting_key, setting_value,
overridden_by, overridden_at, lifted_at, note
audit_log
id, actor_id, actor_type, action, target_type, target_id,
before_json, after_json, ip_address, created_at
(append-only; no UPDATE or DELETE ever issued against this table)
(retention: purge records older than 365 days via monthly APScheduler job)
jwt_blocklist
id, jti, created_at
(stores revoked JWT refresh token JTIs; persisted to DB — survives Gunicorn worker restarts;
checked on every /api/auth/refresh call; purged with expired tokens via APScheduler)
Tenant-Level Tables (all carry tenant_id)
Soft Delete Convention: All tenant models include a
deleted_attimestamp (nullable). Queries always filterWHERE deleted_at IS NULL. Hard deletes are never issued from application code — only via a superadmin maintenance tool. Admin and tenant_admin can restore soft-deleted records.
users
id, tenant_id, email, password_hash, role, is_active,
failed_login_attempts, locked_until,
password_reset_token, password_reset_expires_at,
last_login_at, created_at
tenant_settings id, tenant_id, setting_key, setting_value
locations
id, tenant_id, name, address, phone, email,
timezone, is_active, is_primary, created_at
location_settings id, tenant_id, location_id, setting_key, setting_value
customers
id, tenant_id, name, phone, email, date_of_birth,
preferred_staff_id, notes, loyalty_points,
is_active, no_show_count, created_at, deleted_at
services id, tenant_id, name, category, duration_min, price, is_active, deleted_at
products id, tenant_id, name, sku, category, sale_price, is_active, deleted_at
promotions id, tenant_id, name, discount_percent,
applies_to, target_ids_json,
starts_at, ends_at, is_active, created_by, created_at
(applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all')
(target_ids_json: list of service/product IDs when applies_to is 'service' or 'product';
null when applies_to is 'all_services', 'all_products', or 'all')
(discount_percent: 1–100; e.g. 20 = 20% off)
(active window: starts_at <= NOW() <= ends_at AND is_active = true)
staff id, tenant_id, user_id(FK), name, phone, passcode_hash, deleted_at,
staff_type, pay_type, hourly_rate, salary_amount,
guarantee_amount, pay_period,
commission_rate, commission_enabled,
is_active, passcode_failed_attempts, passcode_locked_until
(staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist')
(pay_type: 'hourly' | 'salary' | 'guarantee')
(hourly_rate: applicable when pay_type = 'hourly'; rate × hours from staff_clockings)
(salary_amount: fixed gross per pay_period when pay_type = 'salary')
(guarantee_amount: minimum guaranteed per pay_period; commission tops it up if higher)
(pay_period: 'weekly' | 'biweekly' | 'monthly')
(commission_enabled: boolean; can be disabled for salaried staff)
(phone: unique within tenant; used for staff-login)
(passcode_hash: bcrypt-hashed 4-6 digit PIN set by owner)
staff_locations id, tenant_id, staff_id, location_id
staff_schedules id, tenant_id, staff_id, location_id, day_of_week, start_time, end_time
appointments
id, tenant_id, location_id, customer_id, staff_id, service_id,
start_time, end_time, is_walk_in, status,
notes, cancellation_reason, cancelled_at,
rebook_source, rebooked_from_transaction_id,
created_by, created_at
(status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show')
(rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk'; nullable — identifies booking origin)
(rebooked_from_transaction_id: FK → transactions.id; nullable — links rebook to its source checkout)
transactions
id, tenant_id, location_id, appointment_id, customer_id, staff_id,
subtotal, discount, tip_amount, gift_card_amount, total,
payment_method, payment_reference, gift_card_id,
review_request_sent_at,
voided_at, voided_by, void_reason, created_at
(payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other')
(payment_reference: app transaction ID or descriptive note, nullable)
(gift_card_id: FK → gift_cards.id; nullable)
(review_request_sent_at: timestamp; NULL = not yet sent; prevents duplicate sends)
transaction_items id, transaction_id, service_id, product_id, qty,
unit_price, original_price, discount_percent, promotion_id
(unit_price: final price after promotion; original_price: price before promotion)
(promotion_id: FK → promotions.id; null if no promotion applied)
inventory
id, tenant_id, location_id, name, sku, category,
qty_on_hand, reorder_level, cost_price, sale_price
inventory_log id, tenant_id, location_id, inventory_id, delta, reason, created_at
commission_log id, tenant_id, location_id, staff_id, transaction_id, amount, period
staff_pay_periods id, tenant_id, staff_id, period_start, period_end,
pay_type, base_amount, commission_amount,
guarantee_topup, total_amount, status, notes
(base_amount: salary, or hourly_rate × total hours, for the period)
(guarantee_topup: max(0, guarantee_amount - commission_amount) when pay_type = 'guarantee')
(status: 'draft' | 'approved' | 'paid')
staff_clockings id, tenant_id, location_id, staff_id,
clocked_in_at, clocked_out_at, total_minutes, notes
(one row per shift; clocked_out_at NULL = currently clocked in)
marketing_campaigns
id, tenant_id, name, channel, status, audience_filter_json,
subject, message_body, scheduled_at, sent_at,
sent_count, open_count
(channel: 'email'; 'sms' in future phase)
(status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled')
gift_cards id, tenant_id, code, original_value, remaining_balance,
issued_by, issued_to_customer_id, expires_at,
is_active, created_at
(code: unique per tenant; generated on issuance)
checkin_queue id, tenant_id, location_id, customer_id,
customer_name, customer_phone, service_requested,
checked_in_at, status, acknowledged_by, acknowledged_at
(status: 'waiting' | 'acknowledged' | 'seated' | 'expired')
(customer_id: FK → customers.id; nullable if new customer not yet profiled)
(service_requested: free-text from kiosk service selector; nullable)
(acknowledged_by: FK → users.id; the receptionist who handled the alert)
waitlist id, tenant_id, location_id, customer_name, customer_phone,
customer_email, staff_id, service_id,
requested_date, status, notified_at, created_at
(status: 'waiting' | 'notified' | 'booked' | 'expired')
checkout_reviews id, tenant_id, location_id, transaction_id, customer_id,
staff_id, rating, comment, is_public_suggested,
google_clicked, facebook_clicked, yelp_clicked,
created_at
(rating: 1–5 integer)
(is_public_suggested: true if rating ≥ 4 — platform links were shown)
(google/facebook/yelp_clicked: tracked via redirect link for analytics)
daily_reconciliations
id, tenant_id, location_id, date, total_cash, total_app_payments,
total_tips, total_gift_card_redemptions, expected_cash_in_drawer,
actual_cash_counted, variance, closed_by, closed_at, notes
(variance: actual_cash_counted - expected_cash_in_drawer; negative = shortage)
appointment_reminders
id, tenant_id, location_id, appointment_id, reminder_type,
scheduled_for, sent_at, channel, status
(reminder_type: '24h' | '2h')
(channel: 'email'; 'sms' in future phase)
(status: 'pending' | 'sent' | 'failed' | 'cancelled')
User Roles & Access Control
System Admin Domain (admin.mydomain.com)
| Role | Capabilities |
|---|---|
superadmin |
Full platform access: system users, tenants, plans, billing, settings override, audit log, analytics |
Future: a
support_agentrole (read-only tenant access) for helpdesk use.
Tenant Domain (mydomain.com)
System roles control application access. Staff job types describe employment classification. They are independent — the mapping below is the convention to follow:
| System Role | Staff Job Types | Location Access | Capabilities |
|---|---|---|---|
tenant_admin |
Salon owner | All locations | Full salon access: all modules, settings, location management, user management; sets/resets staff passcodes |
tenant_manager |
salon_manager |
All locations | Appointments, POS, customers, inventory, reports; no settings, no user management; can reset staff passcodes |
tenant_staff |
full_time, part_time, seasonal, receptionist |
Assigned locations only | Staff Portal only (via phone + passcode login): own schedule, appointments, working hours, pay summary, commission, payment history; no access to manager/admin routes |
Staff job types (stored as staff.staff_type):
| Job Type | Description |
|---|---|
salon_manager |
Manages day-to-day operations; mapped to tenant_manager system role |
full_time |
Full-time employed staff; typically salary or hourly pay |
part_time |
Part-time employed staff; typically hourly pay |
seasonal |
Temporary seasonal staff; hourly or guarantee pay |
receptionist |
Front-desk staff; handles bookings and walk-ins; hourly or salary pay |
Staff pay types (stored as staff.pay_type):
| Pay Type | How it works | Commission |
|---|---|---|
hourly |
hourly_rate × total hours clocked in the pay period |
Optional; added on top of base hourly pay |
salary |
Fixed salary_amount per pay_period regardless of hours |
Optional; can be disabled via commission_enabled = false |
guarantee |
Guaranteed minimum guarantee_amount per period; if commission exceeds guarantee, staff earns commission only — no double-dipping |
Always enabled; guarantee is the floor |
Role enforcement: @require_role(*roles) decorator on every blueprint route.
Location enforcement: load_location_context() validates tenant_staff membership before setting g.location.
Subscription Plans
| Plan | Monthly Price | Max Staff | Max Locations | Features |
|---|---|---|---|---|
| Starter | $29 | 3 | 1 | POS (cash + app + tips + gift cards), appointments, customers, services, promotions, appointment reminders, customer reviews, end-of-day reconciliation, basic reports |
| Growth | $59 | 10 | 3 | + Inventory, commission tracking, full reports, multi-location, online customer booking, waitlist |
| Pro | $99 | Unlimited | Unlimited | + Email marketing campaigns |
Plan Enforcement
load_tenant_context()checkstenant.statuson every request:active,trial,suspended,cancelled.- Feature flags in
plans.features_json; enforced via@tenant_feature_required('marketing')decorator. - Staff and location count limits enforced at creation time (checked against
plan.max_staff,plan.max_locations). - Suspended tenants → locked page with billing instructions.
- Trial: 14 days; APScheduler sends reminder emails at day 7 and day 13.
tenant_setting_overridescan force-enable or force-disable features regardless of plan (superadmin only).
Project Structure
salon_pos/
├── app/
│ ├── __init__.py # App factory (create_app)
│ ├── extensions.py # db, login_manager, jwt, migrate, limiter, scheduler
│ ├── context.py # load_tenant_context(), load_location_context()
│ ├── decorators.py # @require_role, @tenant_feature_required, @demo_readonly
│ ├── security.py # HTTP headers middleware, input sanitiser helpers
│ ├── models/
│ │ ├── platform.py # SystemUser, Tenant, Plan, BillingHistory,
│ │ │ # TenantSettingOverride, AuditLog
│ │ └── salon.py # Location, LocationSetting, User, Customer,
│ │ # Service, Product, Staff, StaffLocation,
│ │ # StaffSchedule, Appointment, Transaction,
│ │ # TransactionItem, Inventory, InventoryLog,
│ │ # CommissionLog, MarketingCampaign,
│ │ # GiftCard, Waitlist, CheckoutReview,
│ │ # DailyReconciliation, AppointmentReminder
│ │
│ ├── admin/ # ← admin.mydomain.com
│ │ ├── __init__.py # create_admin_app()
│ │ ├── auth/
│ │ ├── system_users/
│ │ ├── tenants/
│ │ ├── plans/
│ │ ├── billing/
│ │ ├── settings_override/
│ │ ├── audit_log/
│ │ └── analytics/
│ │
│ ├── tenant/ # ← mydomain.com (Jinja2 UI)
│ │ ├── __init__.py # create_tenant_app()
│ │ ├── auth/ # Email+password login, logout, password reset, demo shortcut
│ │ ├── staff_auth/ # Phone + passcode login / logout for tenant_staff
│ │ ├── checkin/ # Customer self check-in kiosk — no auth (/checkin/{slug})
│ │ ├── dashboard/
│ │ ├── locations/ # Location CRUD + switcher
│ │ ├── customers/
│ │ ├── appointments/
│ │ ├── services/ # Services + products menu + promotions
│ │ ├── pos/ # Checkout, void, receipts, rebook-at-checkout
│ │ ├── staff/ # Profiles, schedules, commission, passcode management
│ │ ├── staff_portal/ # Staff Portal: personal schedule, hours, commission, payments
│ │ ├── booking/ # Public customer booking — no auth (/book/{slug})
│ │ ├── waitlist/ # Waitlist management
│ │ ├── gift_cards/ # Gift card issuance and management
│ │ ├── reviews/ # Review dashboard (owner view of ratings)
│ │ ├── reconciliation/ # End-of-day close + variance
│ │ ├── inventory/
│ │ ├── marketing/ # Email campaigns (Pro plan)
│ │ ├── reports/
│ │ └── settings/
│ │
│ ├── api/ # ← REST API (/api/v1/)
│ │ ├── __init__.py # register_api(app)
│ │ ├── auth/ # JWT issue / refresh / revoke
│ │ ├── v1/
│ │ │ ├── locations.py
│ │ │ ├── customers.py
│ │ │ ├── appointments.py
│ │ │ ├── services.py
│ │ ├── promotions.py # Promotion CRUD + active promotion resolver
│ │ │ ├── pos.py
│ │ │ ├── staff.py
│ │ │ ├── staff_portal.py # Staff-facing personal data endpoints
│ │ │ ├── checkin.py # Kiosk check-in submission + queue push
│ │ │ ├── booking.py # Public booking availability + submission
│ │ │ ├── gift_cards.py # Gift card CRUD + redemption
│ │ │ ├── reviews.py # Review submission + owner dashboard
│ │ │ ├── reconciliation.py # End-of-day close + variance
│ │ │ ├── inventory.py
│ │ │ ├── reports.py
│ │ │ └── settings.py
│ │ └── errors.py # Standardised JSON error responses
│ │
│ ├── templates/
│ │ ├── admin/
│ │ └── tenant/
│ └── static/
│ ├── admin/
│ └── tenant/
│
├── migrations/
├── config.py # Config: Dev, Prod, Test
├── wsgi_admin.py
├── wsgi_tenant.py
├── requirements.txt
├── .env.example
├── deploy/
│ ├── salon_pos_admin.service
│ ├── salon_pos_tenant.service
│ ├── nginx.conf
│ └── backup/
│ ├── db_backup.sh
│ └── backup.cron
└── tests/
├── test_admin_auth.py
├── test_tenant_auth.py
├── test_tenancy_isolation.py
├── test_location_scoping.py
├── test_demo_readonly.py
├── test_settings_override.py
├── test_pos.py
├── test_api_v1.py
├── test_staff_login.py
├── test_promotions.py
├── test_gift_cards.py
├── test_reviews.py
├── test_public_booking.py
├── test_checkin_kiosk.py
├── test_rebook_at_checkout.py
└── test_security_headers.py
Two Flask app factories (
create_admin_app,create_tenant_app) share models and database but have separate blueprint sets, login managers, JWT instances, and session cookies. Two independent Gunicorn processes, two Unix sockets.
Nginx Configuration
# ── 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;
# IP allowlist — office / VPN only
allow 203.0.113.0/24;
deny all;
# Security headers
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;
# Security headers
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;
}
}
# ── HTTP → HTTPS redirect ────────────────────────────────────────
server {
listen 80;
server_name admin.mydomain.com mydomain.com;
return 301 https://$host$request_uri;
}
systemd Units
Admin portal — salon_pos_admin.service
[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
Tenant portal — salon_pos_tenant.service
[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
Development Phases
Phase 1 — Foundation ✅ COMPLETE
- Project scaffold: two app factories (
create_admin_app,create_tenant_app), sharedextensions.py,config.py(Dev/Prod/Test) - Platform models:
SystemUser,Tenant,Plan,TenantBillingHistory,TenantSettingOverride,AuditLog,JWTBlocklist - Tenant-level models: all 26 models including
Location,User,Staff,Appointment,Transaction,CheckinQueue,JWTBlocklist, etc. load_tenant_context()+load_location_context()before-request hooks- Admin portal auth — login/logout, brute-force lockout (5 attempts → 15 min), password reset with time-limited token
- Tenant portal auth — login/logout, password reset, brute-force lockout, demo login shortcut
- Staff portal auth — phone + passcode login, per-staff brute-force lockout, logout
@require_roledecorator — role enforcement on all routes@tenant_feature_requireddecorator — plan feature flag gating@demo_readonlydecorator — blocks all write operations on demo tenantapp/security.py— security headers middleware (CSP with CDN allowance), IP allowlist, input sanitisersapp/decorators.py,app/context.py,app/forms.py— cross-cutting concerns- Base templates — admin portal (Bootstrap 5, sidebar nav) + tenant portal (Bootstrap 5, sidebar nav, location switcher)
- Auth templates — login, password reset (request + confirm), account locked, staff PIN login, feature unavailable
- Database migrations — circular FK resolved (
appointments↔transactionsviause_alter=True);db upgradeverified wsgi_admin.py+wsgi_tenant.pyGunicorn entrypointsdeploy/salon_pos_admin.service+deploy/salon_pos_tenant.servicesystemd unitsdeploy/nginx.conf— both domains (posadmin.ngodanguyen.tech,pos.ngodanguyen.tech), TLS, CSP updated for Bootstrap CDNdeploy/backup/db_backup.sh+backup.cron— daily 02:00, 30-day retentiontests/—conftest.py,test_admin_auth.py,test_tenant_auth.py,test_staff_login.py,test_tenancy_isolation.py,test_security_headers.pyREADME.md— full deployment runbook (fresh install, migrations, seeding, service management, backup)- Demo account pre-seeded data (deferred — requires Phase 2 tenant creation flow)
Phase 2 — Admin Portal ✅ COMPLETE
- System user management (CRUD, force password reset, activate/deactivate)
- Tenant management (create with owner account + primary location, edit, suspend/cancel/activate, assign plan)
- Plan management (create/edit, feature flags via checkboxes, max staff/locations, activate/deactivate)
- Billing history (manual invoice entry per tenant, global list view, per-tenant view)
- Tenant settings override (set with note, lift individually, history view, before/after audit trail)
- Audit log viewer (filter by actor/action/target/date, paginated, CSV export)
- Platform analytics dashboard (MRR, active/trial/churn counts, revenue 30d, trials expiring soon, plan distribution)
app/admin/utils.py—@superadmin_required,log_admin_action,model_to_dictshared helpers- All Phase 2 blueprints registered in
create_admin_app()factory - Admin base template nav links wired to all Phase 2 routes
tests/test_admin_phase2.py— full test suite for all 7 modules- Demo account creation deferred — available via
POST /tenants/newwithis_demo=1
Phase 3 — Multi-Location & Tenant Core Modules
- Location management (CRUD, primary flag, per-location settings)
- Location switcher UI +
tenant_stafflocation restriction enforcement - Staff ↔ location assignment (many-to-many)
- Staff passcode management (set at creation, reset by admin/manager, never stored in plaintext)
- Dashboard (KPIs scoped to active location)
- Customers (CRUD, visit history, loyalty, birthday, preferred staff, search)
- Services & products (tenant-wide catalogue)
- 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 tocheckin_queue; receptionist alert via 5-second polling (GET /api/v1/checkin/queue?status=waiting); auto-reset timer; rate limiting;service_requestedselector) - 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) - POS / Checkout (tip field; gift card redemption; cash / Zelle / Venmo / CashApp / Other; auto-apply active promotions per line item; show original price + discount + final price; void with reason; receipt PDF with tip, promotional savings, gift card balance; email digital receipt option)
- Next-visit scheduling at checkout (optional rebook prompt after payment confirmed; date/time/staff picker; creates new
pendingappointment;rebook_source = checkout; next visit shown on receipt; 24h reminder auto-scheduled) - Gift cards (issuance with unique code; POS redemption; balance tracking; expiry)
- End-of-day reconciliation (Close Day flow; cash count entry; variance calculation;
daily_reconciliationsrecord)
Phase 4 — Tenant Operations Modules
- Staff management (profiles, job type, system role, location assignments, schedules)
- Pay structure setup per staff member (pay type, rates, pay period, commission toggle)
- Commission tracking (per transaction, period summary; respects
commission_enabledflag per staff) - Working hours / clockings (clock-in at login or manual; clock-out; total hours computed per period)
- Pay period calculation engine: hourly (rate × hours), salary (fixed), guarantee (max of guarantee vs commission); results written to
staff_pay_periods - Pay period approval workflow (draft → approved → paid;
tenant_adminapproves) - Inventory (per location: stock, reorder alerts, adjustment log)
- Automatic inventory deduction on POS sale
- Appointment reminder engine (APScheduler queues 24h + 2h email reminders on booking creation/update; cancels pending reminders on appointment cancellation; records in
appointment_reminders) - Customer review request engine (APScheduler sends review email X min post-checkout; smart routing — rating ≥ 4 shows Google/Facebook/Yelp links; rating < 4 goes to silent internal feedback; click-through tracking via redirect; one send per transaction enforced by
review_request_sent_at)
Phase 5 — REST API
- JWT auth (issue, refresh, revoke; httpOnly cookie transport)
GET/POST/PUT/DELETEendpoints for all core resources under/api/v1/- Location-scoped API context (JWT payload:
tenant_id+location_id) - Staff Portal API endpoints (
/api/v1/staff-portal/schedule,/clockings,/commission,/payments,/pay-summary) @demo_readonlyenforced on API write endpoints- Standardised JSON error responses
- API rate limiting (Flask-Limiter)
- OpenAPI/Swagger spec generation
Phase 6 — Reports, Marketing & Backup
- Revenue reports (by period, staff, service, location; CSV + PDF export)
- Promotion performance reports (discount given per promotion, revenue impact, usage count per period)
- Commission reports (by period, staff; CSV export)
- Staff pay period reports (base pay + commission + guarantee top-up per staff per period; CSV + PDF; used for payroll reference)
- Inventory reports (low stock, valuation per location)
- Tip reports (by staff, by period)
- Gift card reports (issued, redeemed, outstanding balance)
- No-show and cancellation reports (by customer, by staff, by period)
- Rebook rate report (% of checkouts with a next visit scheduled; by period, by staff)
- Check-in kiosk usage report (kiosk vs manual walk-in entries per day per location)
- Review analytics report (average rating, response rate, rating distribution, platform click-through)
- End-of-day reconciliation history report (variance trends by location)
- Email marketing campaigns (audience filter, composer, scheduled send, delivery tracking)
- APScheduler: trial reminders (day 7, day 13), monthly report emails, audit log purge job
- Platform analytics (MRR, churn, active tenants, trial conversions)
db_backup.sh+backup.cron(daily 02:00, 30-day retention)
Phase 7 — Hardening & Deployment
- CSRF protection (Flask-WTF) on both portals
- Brute-force lockout verified end-to-end for both login flows (email and phone+passcode)
- Session idle timeout (30 min tenant portal, 60 min admin portal)
- File upload validation:
secure_filename, extension allowlist, size cap (5 MB) - Admin portal IP allowlist: Nginx + Flask double-check
- Separate
SESSION_COOKIE_NAMEper app - Security headers verified: CSP, HSTS, X-Frame-Options, X-Content-Type-Options
- Automated tests (pytest): all suites including
test_security_headers.py,test_demo_readonly.py - Soft-delete verified across all tenant models (
deleted_atfiltering enforced; restore flow for admin and tenant_admin) - No-show counter wired to customer profile and no-show/cancellation reports
- Tenant health dashboard in admin portal (last login, appointment volume trend, days to expiry, billing status)
- Backup restore drill: verify
salon_pos_*.sql.gzrestores cleanly to staging - Deployment runbook (README): fresh install, migration, service management, backup setup
Security Architecture
Authentication
| Concern | Measure |
|---|---|
| Password hashing | bcrypt, cost factor ≥ 12 |
| Password policy | Minimum 10 characters; must include uppercase, lowercase, digit |
| Brute-force lockout | 5 consecutive failures → account locked for 15 minutes; failed_login_attempts + locked_until columns on both system_users and users |
| Password reset | Time-limited token (1 hour); single-use; stored as password_reset_token (hashed) + password_reset_expires_at |
| Session cookies | Secure, HttpOnly, SameSite=Lax; separate SESSION_COOKIE_NAME per app |
| Session idle timeout | 30 min (tenant portal), 60 min (admin portal); enforced server-side via PERMANENT_SESSION_LIFETIME |
| JWT (API) | Short-lived access token (15 min); long-lived refresh token (7 days); transported in httpOnly cookies — never localStorage or sessionStorage |
| JWT revocation | Refresh token revocation via a jwt_blocklist table; checked on every refresh |
| Staff passcode | 4–6 digit PIN; bcrypt-hashed (same cost factor as passwords); set by tenant_admin at staff creation; never returned via API or displayed in UI after creation |
| Staff passcode reset | tenant_admin or tenant_manager can generate a new passcode; old hash immediately invalidated; new passcode shown once then discarded |
| Staff brute-force lockout | 5 failures on /staff-login → passcode_locked_until set for 15 minutes on the staff record; rate-limited at Nginx + Flask-Limiter level |
Authorisation
| Concern | Measure |
|---|---|
| Role enforcement | @require_role(*roles) decorator on every blueprint route |
| Tenant isolation | g.tenant set from authenticated session; all queries filtered by tenant_id |
| 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; 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 |
Input & Output Security
| Concern | Measure |
|---|---|
| SQL injection | SQLAlchemy ORM with parameterized queries only; no raw string interpolation |
| XSS | Jinja2 auto-escaping enabled globally; explicit ` |
| CSRF | Flask-WTF CSRF tokens on all state-changing HTML forms; API uses JWT (stateless, CSRF-exempt) |
| Input validation | WTForms validators on all forms; custom sanitisers strip control characters; regex validation on slugs, setting keys, SKUs |
| File uploads | werkzeug.utils.secure_filename; extension allowlist (jpg, jpeg, png, gif); max 5 MB; stored outside web root |
| Output encoding | All user-generated content rendered via Jinja2 templates (auto-escaped); no innerHTML assignment in JS |
Transport & Infrastructure Security
| Concern | Measure |
|---|---|
| TLS | TLSv1.2 + TLSv1.3 only; strong cipher suite; HSTS (max-age=63072000) |
| HTTP security headers | X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Strict-Transport-Security, Content-Security-Policy — set in Nginx for both domains |
| Secrets management | All secrets in .env (mode 600); never committed to VCS; .env.example contains only placeholder values |
| Database credentials | Separate MySQL user per app (salon_pos_app for the application, salon_pos_backup for backups); principle of least privilege |
| Process isolation | Gunicorn runs as unprivileged salonpos user; no root access |
| Rate limiting | Flask-Limiter on all auth endpoints: 10 req/min on /login; 10 req/min on /staff-login; 5 req/hour on /password-reset |
Audit & Monitoring
| Concern | Measure |
|---|---|
| Superadmin audit log | All create/edit/suspend/override actions logged with before_json, after_json, ip_address; append-only table |
| Audit log retention | 365-day retention; monthly purge job via APScheduler |
| Application logs | Gunicorn access + error logs to /var/log/salon_pos_{admin,tenant}/; log rotation via logrotate |
| Backup integrity | Monthly restore drill to staging environment; documented in deployment runbook |
| Failed login monitoring | failed_login_attempts queryable from admin portal; suspicious patterns visible in audit log |
Environment Variables (.env.example)
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@ngodanguyen.tech
MAIL_PASSWORD=
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
ADMIN_DOMAIN=posadmin.ngodanguyen.tech
TENANT_DOMAIN=pos.ngodanguyen.tech
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
Backup Strategy
Daily automated mysqldump via cron. No external cloud dependency.
deploy/backup/db_backup.sh
#!/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"
deploy/backup/backup.cron
0 2 * * * salonpos /opt/salon_pos/deploy/backup/db_backup.sh >> /var/log/salon_pos_backup.log 2>&1
MySQL backup credentials stored in /etc/mysql/backup.cnf (mode 600, owned by salonpos). Monthly restore drill to staging is mandatory — document results in the runbook.
Phase 1 — Implementation Notes
The following decisions and resolutions were made during Phase 1 scaffold implementation.
Real-Time Alert Mechanism
Polling (5-second setInterval hitting GET /api/v1/checkin/queue?status=waiting) was chosen over SSE and WebSockets for the receptionist dashboard check-in alert. The rationale: the VPS has 4 GB RAM running two Gunicorn processes, MySQL, and Nginx. SSE requires async workers (gevent/eventlet) to avoid blocking Gunicorn worker slots; WebSockets requires Flask-SocketIO plus a message broker (Redis). Polling adds zero infrastructure overhead and the 5-second latency is acceptable for a front-desk use case.
Circular Foreign Key Resolution
appointments.rebooked_from_transaction_id → transactions and transactions.appointment_id → appointments form a circular FK dependency that prevents MySQL InnoDB from creating either table. Both FKs are declared with use_alter=True, name="fk_..." so SQLAlchemy emits them as deferred ALTER TABLE statements after all tables are created. This is the correct permanent fix — not a workaround.
Dual App Factory — Static File Isolation
Each Flask app factory (create_admin_app, create_tenant_app) is configured with:
static_folder="../static/<portal>"— Flask serves directly fromstatic/admin/orstatic/tenant/static_url_path="/static"— both portals use/static/css/...URLs with no portal-name prefix in the path
This eliminates the double-prefix bug (/static/admin/admin/css/...) that occurs when static_folder points at the parent static/ directory.
Content Security Policy — CDN Allowance
Bootstrap 5 CSS/JS and Bootstrap Icons are loaded from cdn.jsdelivr.net. The CSP must explicitly allow this CDN in script-src, style-src, and font-src directives. This is set in both app/security.py (Flask layer) and deploy/nginx.conf (Nginx layer, which takes precedence in production). Both must be consistent or Nginx will override Flask's permissive header with the restrictive one.
Admin Portal Login URL
The admin auth blueprint uses url_prefix="" (not /admin). The login page is at posadmin.ngodanguyen.tech/login. Visiting / redirects to /login for unauthenticated users and to /dashboard for authenticated ones.
JWT Blocklist Table Location
jwt_blocklist is defined in app/models/platform.py (not salon.py) because it is a platform-level concern — shared across all tenants, not scoped to any one salon. Refresh token revocation must survive Gunicorn worker restarts, so in-memory storage is not used.
Template Path Resolution
Both portal apps set template_folder to the project-root templates/ directory. All render_template() calls and {% extends %} directives must use the full path from that root:
- Admin templates:
"admin/auth/login.html",{% extends "admin/layouts/base.html" %} - Tenant templates:
"tenant/auth/login.html",{% extends "tenant/layouts/base.html" %}
Phase 2 Blueprint Stubs
All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to url_for('tenants.index'), url_for('system_users.index') etc. are replaced with # until Phase 2 routes are implemented.
Decisions Log
| # | Decision | Resolution |
|---|---|---|
| 1 | Payment methods | Cash + app payments (Zelle, Venmo, CashApp) now. Stripe/card deferred to future phase. |
| 2 | Multi-location | locations table under one tenant. Tenant admin manages 1–N salons; active location via session switcher. |
| 3 | API layer | REST API (/api/v1/) alongside Jinja2 UI; JWT via httpOnly cookies. Required for future mobile/PWA. |
| 4 | Backup | Daily mysqldump + gzip via cron at 02:00. 30-day local retention. Monthly restore drill. |
| 5 | Marketing channel | Email campaigns now (SMTP). SMS (Twilio) deferred to future phase. |
| 6 | Tenant onboarding | Shared demo at mydomain.com/demo (read-only, pre-seeded, write-blocked by @demo_readonly). Official tenants provisioned by superadmin only. |
| 7 | Staff login | Separate mydomain.com/staff-login flow using phone number + 4–6 digit passcode. Passcode bcrypt-hashed, set by owner at registration. Staff land on a personal Staff Portal (schedule, hours, commission, payments). |
| 8 | Staff roles & pay | Job types: salon_manager, full_time, part_time, seasonal, receptionist. Pay types: hourly (rate × hours), salary (fixed per period), guarantee (minimum floor topped up by commission). Commission optional for hourly/salary; always active for guarantee. |
| 9 | Promotions | Services and products support percentage-off promotions with a defined start/end date window. The checkout engine resolves and applies active promotions automatically per line item — no manual entry needed. Original price, discount %, and final price are stored on each transaction_item for audit and reporting. |
| 10 | Online booking & waitlist | Public booking page at /book/{slug} (no auth; Growth + Pro plans). Waitlist joins when slots are full; auto-email notification on cancellation. |
| 11 | Tip tracking | Tip field at POS; attributed to serving staff; visible in Staff Portal and pay reports. Included in end-of-day reconciliation totals. |
| 12 | Gift cards | Issued by tenant_admin with unique code and set value; redeemed at POS as payment method; balance decremented per use; tracked in gift_cards table with expiry. |
| 13 | Customer reviews & reputation | Review request email sent X min after checkout (default 60 min). Rating ≥ 4 → public review links (Google, Facebook, Yelp); rating < 4 → private internal feedback only. One send per transaction. Platform URLs in salon settings. Click-through tracked. |
| 14 | Appointment reminders | Automated email 24h before appointment (+ optional 2h). Configurable per tenant. Reduces no-show rate. SMS deferred. |
| 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 surfaces an alert on the receptionist dashboard via 5-second polling (GET /api/v1/checkin/queue?status=waiting) — chosen over SSE/WebSockets to minimise resource usage on a constrained VPS. Page auto-resets after 10 seconds. Rate-limited; slug allowlist prevents enumeration. |
| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as pending with rebook_source = checkout. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. |
| 20 | Real-time alert mechanism | 5-second polling chosen over SSE/WebSockets for receptionist check-in alerts. VPS has 4 GB RAM — SSE needs async Gunicorn workers; WebSockets needs Flask-SocketIO + Redis. Polling adds zero infrastructure overhead. Endpoint: GET /api/v1/checkin/queue?status=waiting. |
| 21 | Circular FK resolution | appointments.rebooked_from_transaction_id → transactions and transactions.appointment_id → appointments form a cycle. Both FKs use use_alter=True so SQLAlchemy defers them as ALTER TABLE statements post-creation. |
| 22 | Static file serving | Each app factory points static_folder to static/admin/ or static/tenant/ directly, with static_url_path="/static". Eliminates double-prefix URL bug (/static/admin/admin/css/...). |
| 23 | Content Security Policy | CSP allows cdn.jsdelivr.net in script-src, style-src, and font-src for Bootstrap 5 and Bootstrap Icons. Must be set consistently in both app/security.py and deploy/nginx.conf — Nginx overrides Flask headers in production. |
| 24 | Admin login URL | Admin auth blueprint uses url_prefix="". Login page is at posadmin.ngodanguyen.tech/login. Root / redirects to /login (unauthenticated) or /dashboard (authenticated). |
| 25 | JWT blocklist table location | jwt_blocklist defined in platform.py (not salon.py) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts. |
| 26 | Template path convention | template_folder points to project-root templates/. All render_template() calls and {% extends %} use full paths: "admin/auth/login.html", "admin/layouts/base.html", "tenant/auth/login.html", etc. |
| 27 | Production domains | Admin portal: posadmin.ngodanguyen.tech. Tenant portal: pos.ngodanguyen.tech. Updated in .env, nginx.conf, and all documentation. |