A multi-tenant restaurant SaaS POS system. Each restaurant gets its own isolated subdomain, JWT-scoped auth, and a live order board. Customers scan a QR code at their table, order with no login, and the order flows in real time through Kitchen → Waiter → Cashier dashboards via WebSocket.
Built as a solo portfolio project targeting software development and full-stack internship applications.
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite, React Router v6, @stomp/stompjs |
| Backend | Spring Boot 4.x, Java 25, Spring Security, Spring Data JPA |
| Database | PostgreSQL 16 (Docker) |
| Real-time | WebSocket (STOMP over native WebSocket) |
| Auth | JWT (jjwt), BCrypt password hashing |
| Dev environment | Fedora Linux, Docker Compose |
- Each restaurant gets a unique subdomain (
everest.yourapp.com) - All restaurants share one database — tenant isolation enforced via
restaurant_idscoping on every query - Cross-tenant token rejection: a JWT from Restaurant A is rejected on Restaurant B's subdomain
TenantResolutionFilterresolves the active tenant from theHostheader on every request
- Customers scan a QR code at their table — no login required
- Order moves through a state machine:
PLACED → PREPARING → READY → SERVED → PAID - Every status change broadcasts instantly to all connected dashboards via WebSocket (STOMP)
- Separate role-gated dashboards for Kitchen, Waiter, and Cashier
- Five roles:
PLATFORM_ADMIN,ADMIN,KITCHEN,WAITER,CASHIER - JWT carries role and restaurant ID — validated on every request
- Role-gated status transitions: Kitchen can only move PLACED→PREPARING and PREPARING→READY; Waiter can only mark SERVED; Cashier can only mark PAID
- Deleted users are force-logged-out within seconds (DB existence check on every authenticated request)
- Menu management: add/disable/enable/delete items
- Staff management: create/remove Kitchen, Waiter, Cashier accounts with role badges
- Table management: create tables, each gets a UUID-based QR token, QR code rendered inline
- Every restaurant has a subscription record (
ACTIVE,GRACE_PERIOD,BLOCKED) SubscriptionFilterenforces blocking — blocked restaurants get402 Payment Requiredon all API calls except menu browsing- Platform admin dashboard: list all restaurants, view subscription status, manually block/unblock/set grace period
- Public signup flow: restaurant name, subdomain (validated, unique), admin credentials
- Creates restaurant record, admin user, and 30-day ACTIVE subscription in one transaction
- Redirects to the new restaurant's subdomain admin panel already logged in
Browser (React/Vite)
│
├── Customer: /menu?table=<qrToken>&tenant=<subdomain>
│ └── Scans QR → views menu → places order (no login)
│
├── Staff dashboards: /kitchen, /waiter, /cashier
│ └── JWT auth → WebSocket subscription → live order updates
│
└── Admin: /admin
└── JWT auth (ADMIN role) → menu/staff/table management
Spring Boot Backend (port 8080)
│
├── TenantResolutionFilter → resolves restaurant_id from Host header or ?tenant= param
├── JwtAuthFilter → validates JWT, sets Spring Security context
├── SubscriptionFilter → blocks requests for BLOCKED restaurants (402)
│
├── REST API
│ ├── POST /api/onboard → restaurant signup (public)
│ ├── POST /api/auth/login → returns JWT (public)
│ ├── GET/POST/DELETE /api/menu-items → menu CRUD (GET public, mutations ADMIN)
│ ├── GET/POST /api/orders → order list + creation (GET staff, POST public)
│ ├── PATCH /api/orders/{id}/status → role-gated status transitions
│ ├── /api/tables/** → table management (ADMIN)
│ ├── /api/auth/users → staff management (ADMIN)
│ └── /api/platform/** → platform admin (PLATFORM_ADMIN)
│
└── WebSocket /ws (STOMP)
└── /topic/restaurant/{id}/orders → broadcasts on every order create/update
PostgreSQL
├── restaurants (id, name, subdomain)
├── users (id, restaurant_id, name, email, password_hash, role)
├── menu_items (id, restaurant_id, name, price, available)
├── tables (id, restaurant_id, table_number, qr_token)
├── orders (id, restaurant_id, table_id, status, total_amount, created_at)
├── order_items (id, order_id, menu_item_id, menu_item_name, quantity, price_at_order_time)
└── subscriptions (id, restaurant_id, status, current_period_end, last_payment_date)
- Java 21+ (project uses Java 25)
- Node.js 18+
- Docker + Docker Compose
docker compose up -dPostgres runs on port 5433 (not the default 5432, to avoid conflicts with any native install).
cd backend
./mvnw spring-boot:runBackend runs on http://localhost:8080.
cd frontend
npm install
npm run devFrontend runs on http://localhost:5173.
For subdomain routing to work locally (everest.localhost:5173), most modern browsers resolve *.localhost automatically. If yours doesn't, add entries to /etc/hosts:
127.0.0.1 everest.localhost
Visit http://localhost:5173 → "Create your restaurant" → fill in the signup form. You'll be redirected to your restaurant's admin panel at http://<subdomain>.localhost:5173/admin.
| Variable | Default (dev) | Description |
|---|---|---|
JWT_SECRET |
hardcoded dev string | Secret key for JWT signing — must be overridden in production |
spring.datasource.password |
changeme |
Postgres password |
- JWT secret is hardcoded in
application.propertiesas a dev fallback — setJWT_SECRETenv var before any real deployment - JWTs are stored in
localStorage— vulnerable to XSS; httpOnly cookies would be more correct for production - WebSocket uses
ws://— must becomewss://once served over HTTPS - Nginx requires explicit WebSocket upgrade config (
Upgrade/Connectionheaders) when used as a reverse proxy - In-memory STOMP broker — works for a single instance; switch to Redis/RabbitMQ for horizontal scaling
- No automated subscription billing — payment marking is manual via platform admin
| Phase | Status |
|---|---|
| Phase 0 — Environment setup | ✅ Complete |
| Phase 1 — Core order loop (QR → Kitchen → Waiter → Cashier → Paid) | ✅ Complete |
| Phase 2 — Auth + role-based access | ✅ Complete |
| Phase 3 — Multi-tenancy + onboarding | ✅ Complete |
| Phase 4 — Subscriptions + platform admin | ✅ Complete |
| Phase 5 — Polish + demo prep | 🔶 In progress |