# 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_admin` and `tenant_manager`; `tenant_staff` is 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_admin` at staff creation time; passcode reset by `tenant_admin` or `tenant_manager` - **Staff Portal** *(accessed via `mydomain.com/staff-login` — phone number + passcode only)* — personal self-service view for `tenant_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 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 - **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_admin` and `tenant_manager`; cash or app-based payment (Zelle, Venmo, CashApp, Other); `payment_reference` field 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` (or `confirmed` if 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_source` field on `appointments` table 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_admin` issues 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_cards` table 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_reconciliations` table; variance report visible to `tenant_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`: 1. User submits email + password. 2. Flask-Login loads the `User` record; `user.tenant_id` stored in session. 3. `@before_request` hook calls `load_tenant_context()` → sets `g.tenant`; checks `tenant.status`. 4. `load_location_context()` resolves `g.location` from session (last selected) or defaults to primary location. 5. All downstream queries filter by `g.tenant.id` and `g.location.id`. 6. If `tenant.status` is `suspended` or `cancelled`, user is redirected to a locked page. **Phone + passcode flow** (`mydomain.com/staff-login`) — for `tenant_staff`: 1. Staff submits phone number + 4–6 digit passcode. 2. Flask looks up the `Staff` record by `(phone, tenant_id)`; `tenant_id` is 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). 3. Passcode verified against `staff.passcode_hash` (bcrypt). 4. Brute-force lockout: 5 failures → `passcode_locked_until` set for 15 minutes. 5. On success, a Flask-Login session is created scoped to the matched tenant and the staff's primary assigned location. 6. Staff are redirected to `/staff/portal` — the simplified personal dashboard. 7. `tenant_staff` sessions cannot access any owner/manager routes; `@require_role` enforces this. On `admin.mydomain.com`, there is no `g.tenant` — all queries are unscoped platform-level queries. ### Data Isolation Rules 1. Every tenant-owned model MUST have `tenant_id` (FK → `tenants.id`, non-nullable, indexed). 2. Location-specific models MUST also carry `location_id` (FK → `locations.id`, indexed). 3. All blueprint query helpers MUST filter by `g.tenant.id` — never raw `Model.query.all()`. 4. Location-scoped queries additionally filter by `g.location.id`. 5. `tenant_staff` location access is enforced by checking `staff_locations` membership before `g.location` is set. 6. Superadmin routes bypass tenant scoping entirely. 7. Cross-tenant data access is only permitted from the superadmin context. 8. Settings overrides in `tenant_setting_overrides` take precedence over `tenant_settings` at the application layer. 9. Demo tenant is write-protected at the application layer: any `POST`/`PUT`/`DELETE` request 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_at` timestamp (nullable). Queries always filter `WHERE 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_agent` role (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()` checks `tenant.status` on 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_overrides` can 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 ```nginx # ── 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` ```ini [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` ```ini [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 - [x] Project scaffold: two app factories (`create_admin_app`, `create_tenant_app`), shared `extensions.py`, `config.py` (Dev/Prod/Test) - [x] Platform models: `SystemUser`, `Tenant`, `Plan`, `TenantBillingHistory`, `TenantSettingOverride`, `AuditLog`, `JWTBlocklist` - [x] Tenant-level models: all 26 models including `Location`, `User`, `Staff`, `Appointment`, `Transaction`, `CheckinQueue`, `JWTBlocklist`, etc. - [x] `load_tenant_context()` + `load_location_context()` before-request hooks - [x] Admin portal auth — login/logout, brute-force lockout (5 attempts → 15 min), password reset with time-limited token - [x] Tenant portal auth — login/logout, password reset, brute-force lockout, demo login shortcut - [x] Staff portal auth — phone + passcode login, per-staff brute-force lockout, logout - [x] `@require_role` decorator — role enforcement on all routes - [x] `@tenant_feature_required` decorator — plan feature flag gating - [x] `@demo_readonly` decorator — blocks all write operations on demo tenant - [x] `app/security.py` — security headers middleware (CSP with CDN allowance), IP allowlist, input sanitisers - [x] `app/decorators.py`, `app/context.py`, `app/forms.py` — cross-cutting concerns - [x] Base templates — admin portal (Bootstrap 5, sidebar nav) + tenant portal (Bootstrap 5, sidebar nav, location switcher) - [x] Auth templates — login, password reset (request + confirm), account locked, staff PIN login, feature unavailable - [x] Database migrations — circular FK resolved (`appointments` ↔ `transactions` via `use_alter=True`); `db upgrade` verified - [x] `wsgi_admin.py` + `wsgi_tenant.py` Gunicorn entrypoints - [x] `deploy/salon_pos_admin.service` + `deploy/salon_pos_tenant.service` systemd units - [x] `deploy/nginx.conf` — both domains (`posadmin.ngodanguyen.tech`, `pos.ngodanguyen.tech`), TLS, CSP updated for Bootstrap CDN - [x] `deploy/backup/db_backup.sh` + `backup.cron` — daily 02:00, 30-day retention - [x] `tests/` — `conftest.py`, `test_admin_auth.py`, `test_tenant_auth.py`, `test_staff_login.py`, `test_tenancy_isolation.py`, `test_security_headers.py` - [x] `README.md` — full deployment runbook (fresh install, migrations, seeding, service management, backup) - [ ] Demo account pre-seeded data (deferred — requires Phase 2 tenant creation flow) ### Phase 2 — Admin Portal - [ ] System user management (CRUD, force password reset) - [ ] Tenant management (create, edit, suspend, cancel, assign plan) - [ ] Plan management (create/edit, feature flags, limits) - [ ] Billing history (manual invoice entry, per-tenant view) - [ ] Tenant settings override (set/lift with before/after audit trail) - [ ] Audit log viewer (filter by actor, action, date; export) - [ ] Platform analytics dashboard ### Phase 3 — Multi-Location & Tenant Core Modules - [ ] Location management (CRUD, primary flag, per-location settings) - [ ] Location switcher UI + `tenant_staff` location 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 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) - [ ] 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 `pending` appointment; `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_reconciliations` record) ### 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_enabled` flag 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_admin` approves) - [ ] 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/DELETE` endpoints 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_readonly` enforced 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_NAME` per 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_at` filtering 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.gz` restores 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 `|safe` filter never used on user input | | 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`** ```bash #!/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/"` — Flask serves directly from `static/admin/` or `static/tenant/` - `static_url_path="/static"` — both portals use `/static/css/...` URLs with no portal-name prefix in the path This eliminates the double-prefix bug (`/static/admin/admin/css/...`) that occurs when `static_folder` points at the parent `static/` directory. ### Content Security Policy — CDN Allowance Bootstrap 5 CSS/JS and Bootstrap Icons are loaded from `cdn.jsdelivr.net`. The CSP must explicitly allow this CDN in `script-src`, `style-src`, and `font-src` directives. This is set in both `app/security.py` (Flask layer) and `deploy/nginx.conf` (Nginx layer, which takes precedence in production). Both must be consistent or Nginx will override Flask's permissive header with the restrictive one. ### Admin Portal Login URL The admin auth blueprint uses `url_prefix=""` (not `/admin`). The login page is at `posadmin.ngodanguyen.tech/login`. Visiting `/` redirects to `/login` for unauthenticated users and to `/dashboard` for authenticated ones. ### JWT Blocklist Table Location `jwt_blocklist` is defined in `app/models/platform.py` (not `salon.py`) because it is a platform-level concern — shared across all tenants, not scoped to any one salon. Refresh token revocation must survive Gunicorn worker restarts, so in-memory storage is not used. ### Template Path Resolution Both portal apps set `template_folder` to the project-root `templates/` directory. All `render_template()` calls and `{% extends %}` directives must use the full path from that root: - Admin templates: `"admin/auth/login.html"`, `{% extends "admin/layouts/base.html" %}` - Tenant templates: `"tenant/auth/login.html"`, `{% extends "tenant/layouts/base.html" %}` ### Phase 2 Blueprint Stubs All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to `url_for('tenants.index')`, `url_for('system_users.index')` etc. are replaced with `#` until Phase 2 routes are implemented. --- ## Decisions Log | # | Decision | Resolution | |---|---|---| | 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. |