尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證Eatlo:針對印度餐廳的 NFC 與 QR 桌面點餐——單一後端提供四種介面,即時廚房顯示,AI 菜單匯入與 CGST/SGST 計費。包含一次訂單在全部三個螢幕上的錄製執行。
AI 翻譯
Eatlo: NFC and QR table ordering for Indian restaurants — four interfaces on one backend, a real-time kitchen display, AI menu import and CGST/SGST billing. Includes a recorded run of one order across all three screens.
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
QR-based, multi-tenant restaurant ordering SaaS for the Indian market — scan, order, track, pay, no app required.
Live: eatlo.in | Role: Solo Developer / Founder | Status: In production, onboarding paying restaurants | Audience: Independent restaurants & small chains in India檢視完整 README ↗QR-based, multi-tenant restaurant ordering SaaS for the Indian market — scan, order, track, pay, no app required.
Live: eatlo.in | Role: Solo Developer / Founder | Status: In production, onboarding paying restaurants | Audience: Independent restaurants & small chains in India
Eatlo replaces the paper-menu-and-shouting-at-waiters experience with a QR-first ordering flow built specifically for the Indian restaurant market: a customer scans the code on their table, browses a live menu in their browser, places an order that lands on a kitchen display in real time, tracks its prep status item-by-item, and pays — all without installing anything. On the other side, kitchen staff manage a live order queue with SLA timers and ticket printing, and owners run the entire operation — menu, staff, tables, discounts, GST reporting — from an admin dashboard.
What makes it interesting technically is that it's a real-time, multi-tenant system with hard financial and regulatory constraints: every restaurant's data must be strictly isolated, every rupee has to be tracked as an exact integer (GST math doesn't forgive floating-point drift), and the system has to stay responsive and consistent across three simultaneous live views (customer, kitchen, admin) of the same order as it moves through its lifecycle. On top of the core ordering loop, the platform layers genuinely product-shaped features — AI-assisted menu onboarding, dynamic wait-time prediction, automatic combo suggestions mined from order history, and a two-tier cancellation system that balances customer flexibility against kitchen workload.
It's built and operated solo, end-to-end: schema design, API, real-time layer, three distinct front-end surfaces, deployment pipeline, and the commercial side (provisioning, billing, support) for a small but real and growing base of paying restaurants in Hyderabad.
/r/:slug/table/:id)/kitchen)/admin)/superadmin)Captured on 17 September 2026 against a throwaway local database, with a fictional restaurant created by the provisioning CLI. Every number below is from the run's own logs.
media/recording.mp4 — 62 seconds, unedited: the diner's phone on the left, the kitchen display and owner dashboard on the right, all three updating over Socket.io as one order moves through.
| The guest's phone, with the bill split into CGST and SGST. | The kitchen display, the moment the order lands. |
| The owner dashboard, cash bill requested. | AI menu import, waiting for the owner's review. |
What the run measured
kitchen, admin and session rooms; status changes took 11–16 ms server-side.gpt-4o-mini call on a photo of a fictional menu, 13.2 s round trip, 26,195 input and 1,070 output tokens, about $0.005. It extracted 12 items across 3 categories and captured the +₹20 garlic add-on as a modifier.And what it exposed. Capturing a real run found four faults that describing one would not have:
bill:requested — the event reaches the kitchen room, but the page has no handler for it.The full event traces are in media/trace-order.json and media/trace-menu-import.json, and the measured facts with the command behind each one are in media/facts.json.
flowchart TB
subgraph Clients["Client Surfaces (React 18 + Vite + Tailwind)"]
Cust["Customer Web App<br/>/r/:slug/table/:id"]
Kitchen["Kitchen Display<br/>/kitchen"]
Admin["Admin Dashboard<br/>/admin"]
Super["Superadmin Console<br/>/superadmin"]
end
subgraph Server["Express API (Node.js)"]
REST["REST Routes<br/>auth · public · kitchen · admin · ratings · superadmin"]
MW["Middleware<br/>JWT auth · RBAC · rate limiting · ownership checks"]
SocketSrv["Socket.io Server<br/>session / kitchen / admin rooms"]
Jobs["Background Jobs<br/>86-restore · combo recompute · overdue-order sweep"]
end
subgraph Data["Data Layer"]
PG[("PostgreSQL<br/>raw SQL via pg, multi-tenant by restaurant_id")]
end
subgraph External["External Services"]
OpenAI["OpenAI gpt-4o-mini<br/>AI menu import"]
Cloudinary["Cloudinary<br/>image hosting"]
Resend["Resend / Nodemailer<br/>transactional email"]
Sentry["Sentry<br/>error tracking"]
end
Cust <-->|HTTPS + WebSocket| REST
Kitchen <-->|HTTPS + WebSocket| REST
Admin <-->|HTTPS + WebSocket| REST
Super -->|HTTPS, secret-header auth| REST
REST --> MW --> PG
REST <--> SocketSrv
SocketSrv <--> Cust
SocketSrv <--> Kitchen
SocketSrv <--> Admin
Jobs --> PG
Jobs --> SocketSrv
REST --> OpenAI
REST --> Cloudinary
REST --> Resend
Server --> Sentry
The system is a modular monolith: one Express app serves four distinct front-ends and hosts the Socket.io server in the same process, sharing a single connection pool to PostgreSQL. That single-process design keeps an order's state changes — REST write → DB commit → socket broadcast — trivially consistent, which matters a lot when three different screens need to agree on what "ready" means within milliseconds. Real-time fan-out is room-based (session:{token}, restaurant:{id}:kitchen, restaurant:{id}:admin) so each surface only receives events relevant to it, and a small set of interval-based background jobs (86-restore, combo recomputation, overdue-order detection) run inside the same process and push their results back out over the same socket layer.
| Category | Technology | Why |
|---|---|---|
| Frontend | React 18, Vite, React Router v6, Tailwind CSS | Fast dev loop and small bundles for a mobile-first ordering UI that has to load instantly from a QR scan on restaurant Wi-Fi |
| Backend | Node.js, Express | Single language across the stack; mature ecosystem for REST + WebSockets in one process |
| Database | PostgreSQL, raw SQL via pg (no ORM) | Full control over query plans and joins for reporting (GST, daily sales, combo mining) — and integer-only arithmetic for money is easier to guarantee without an ORM's type coercion in the way |
| Real-time | Socket.io v4 | Room-based broadcast model maps cleanly onto "restaurant kitchen", "restaurant admin", "customer session" — with automatic reconnection on flaky restaurant Wi-Fi |
| Auth | JWT (7-day) + bcryptjs + revocation list | Stateless verification for normal requests, with a jti-based blacklist so logout / password-reset can still invalidate a token immediately |
| AI | OpenAI gpt-4o-mini | Cheap, fast structured-output model — good enough for the bounded task of turning a photo/text menu into validated JSON, with strict server-side schema checking on the result |
| Media | Cloudinary | Offloads image transformation/CDN delivery for menu photos and logos |
| Resend / Nodemailer | Transactional flows — verification, password reset, staff invites | |
| Monitoring | Sentry | Production error visibility for a solo-maintained system |
| Deployment | Railway (API + DB), Vercel (frontend + marketing) | Push-to-deploy pipeline suited to a small team shipping multiple times a week |
erDiagram
RESTAURANTS ||--o{ RESTAURANT_TABLES : has
RESTAURANTS ||--o{ STAFF : employs
RESTAURANTS ||--o{ MENU_CATEGORIES : organizes
RESTAURANTS ||--o{ MENU_ITEMS : sells
RESTAURANTS ||--o{ DISCOUNTS : offers
RESTAURANTS ||--o{ ANNOUNCEMENTS : publishes
MENU_CATEGORIES ||--o{ MENU_ITEMS : contains
MENU_ITEMS ||--o{ ITEM_MODIFIERS : has
MENU_ITEMS ||--o{ ITEM_RATINGS : receives
MENU_ITEMS ||--o{ COMBO_SUGGESTIONS : "suggests / triggers"
RESTAURANT_TABLES ||--o{ SESSIONS : seats
RESTAURANT_TABLES ||--o{ TABLE_MERGES : "merged in"
SESSIONS ||--o{ ORDERS : produces
SESSIONS ||--o{ PAYMENTS : settles
SESSIONS ||--o{ ITEM_RATINGS : "rates after"
ORDERS ||--o{ ORDER_ITEMS : contains
ORDERS }o--o| DISCOUNTS : "discounted by"
ORDER_ITEMS ||--o{ ORDER_ITEM_MODIFIERS : customised_by
ORDER_ITEMS ||--o{ CANCELLATION_REQUESTS : "may have"
ORDER_ITEMS ||--o{ ITEM_RATINGS : rated_in
DISCOUNTS ||--o{ DISCOUNT_TARGETS : "scoped to"
RESTAURANTS {
uuid id PK
string slug UK
int gst_rate
string gst_type
boolean is_active
}
MENU_ITEMS {
uuid id PK
uuid restaurant_id FK
uuid category_id FK
int price "paise, integer"
string food_type "veg | nonveg | egg"
boolean eighty_sixed
numeric avg_rating
int rating_count
}
ORDERS {
uuid id PK
uuid session_id FK
uuid table_id FK
string status
int subtotal
int gst_amount
int total_amount
int discount_amount
timestamptz expected_ready_at
}
ORDER_ITEMS {
uuid id PK
uuid order_id FK
uuid menu_item_id FK
int item_price "snapshot at order time"
string status
int cancellation_charge
uuid replaced_by_item_id FK
}
SESSIONS {
uuid id PK
uuid restaurant_id FK
uuid table_id FK
uuid session_token UK
string order_type
boolean is_active
}
DISCOUNTS {
uuid id PK
string discount_type "percentage | flat | bxgy"
int discount_value
jsonb active_days
time active_from
time active_until
}
CANCELLATION_REQUESTS {
uuid id PK
uuid order_item_id FK
string reason
string status "pending | approved | rejected"
boolean charge_waived
}
The schema spans 16 incremental migrations (each IF NOT EXISTS/idempotent, so they can run safely on every deploy). Every tenant-scoped table carries a restaurant_id foreign key with ON DELETE CASCADE, which is the backbone of the row-level multi-tenancy model — see Key Technical Decisions below. All monetary columns are stored as integer paise, and a Postgres trigger on item_ratings automatically recomputes each dish's average rating and flips its is_bestseller flag once it crosses a quality threshold — business logic that lives in the database because it has to be correct regardless of which code path inserts a rating.
All routes are versionless and namespaced by audience. 120 endpoints exist in total (118 router routes plus two health checks, counted from the code on 17 September 2026); the most representative are grouped below (full reference in API_DOCUMENTATION.md).
Auth — /api/auth
POST /login — staff login (rate-limited)GET /me — current staff profilePOST /logout — revokes the current JWTPOST /forgot-password / POST /reset-password — email-based reset flowPOST /accept-invite — staff completes an email invitePublic / Customer — /api
POST /session/start — start (or resume) a table session from a QR scanGET /menu/:slug — restaurant's live, schedule-aware menuPOST /orders — place an order (server snapshots prices, applies discounts, calculates GST)GET /orders/session/:token — all orders for the active session, for live trackingPOST /help-alert / POST /bill/request — call-waiter and request-bill actionsPOST /orders/:orderId/items/:itemId/cancel — instant free cancellation (pre-kitchen items)POST /orders/:orderId/items/:itemId/cancel-request — request cancellation of in-progress itemsKitchen — /api/kitchen
GET /orders — live order queue for the restaurantPATCH /orders/:orderId/status / PATCH /items/:itemId/status — move items through the prep lifecyclePOST /tables/merge / DELETE /tables/merge/:mergeId — merge/unmerge tablesPATCH /menu-items/:menuItemId/86 — mark an item temporarily unavailablePATCH /cancel-requests/:requestId/approve — approve or reject a customer cancellation requestAdmin — /api/admin
GET /dashboard — today's stats, weekly chart data, live alertsGET/POST/PUT/DELETE /menu/items — full menu CRUD, plus /menu/import/parse and /menu/import/save for AI-assisted bulk importGET/POST/PATCH/DELETE /discounts and /announcements — offers engineGET /reports/gst / GET /reports/daily — tax and sales reportingPOST /upload-image / POST /upload-logo — Cloudinary-backed media uploadSuperadmin — /api/superadmin (secret-header auth, separate from staff JWT)
POST /provision — create a new tenant restaurantGET /clients / PATCH /clients/:id — client roster and billing managementGET /commission / POST /commission/:id/mark-paid — commission trackingOrder status changes are the highest-frequency real-time event in the system — they have to reach the customer's phone, the kitchen display, and the admin dashboard simultaneously and in the correct order:
sequenceDiagram
participant C as Customer App
participant API as Express API
participant DB as PostgreSQL
participant IO as Socket.io
participant K as Kitchen Display
participant A as Admin Dashboard
C->>API: POST /orders (session_token, items)
API->>DB: BEGIN — snapshot prices, apply discount, calc GST
DB-->>API: order + order_items committed
API-->>C: 201 Created (order summary)
API->>IO: emit "order:new"
IO-->>K: order:new (kitchen room)
IO-->>A: order:new (admin room)
K->>API: PATCH /kitchen/items/:id/status ("preparing")
API->>DB: UPDATE order_items SET status, recalc order totals
API->>IO: emit "item:updated" / "order:updated"
IO-->>C: item:updated (session room) — live tracker updates
IO-->>A: order:updated (admin room)
Note over IO,K: Background job — overdue sweep (every 60s)
IO-->>K: order:overdue (if expected_ready_at has passed)
Rooms are joined with credentials: customers join session:{token} with their session token, while kitchen/admin clients must present a valid staff JWT whose restaurantId matches the room they're requesting — preventing one restaurant's staff from listening in on another's kitchen feed.
1. Raw SQL over an ORM
Decision: All database access goes through parameterised queries via the pg driver — no Sequelize/Prisma/TypeORM.
Why: Reports (GST breakdowns, daily sales, combo mining from 90 days of order history) need hand-tuned joins and aggregates that are awkward to express — and easy to get subtly wrong — through an ORM's query builder. Raw SQL also keeps the integer-paise contract explicit at every boundary, with no risk of an ORM silently coercing a monetary column to a float.
Trade-off: More boilerplate and manual migration management (see decision 2), in exchange for predictable query plans and zero "magic" between the code and what actually runs against the database.
2. Inline, idempotent migrations
Decision: Every schema change is a numbered .sql file using CREATE TABLE IF NOT EXISTS / ALTER TABLE ... ADD COLUMN IF NOT EXISTS, runnable repeatedly without error.
Why: The deploy target (Railway) doesn't run a separate migration step — migrations execute as part of application startup. Idempotency means a redeploy never fails because a migration "already ran," which removes an entire class of deploy-time incidents for a solo-maintained production system.
Trade-off: No down-migrations or rollback tooling; schema evolution is strictly additive. Acceptable for a system at this stage, but it's a deliberate scope cut that would need revisiting at larger scale.
3. Multi-tenancy via shared schema + row-level scoping + defense-in-depth ownership checks
Decision: All restaurants share one schema; every tenant-owned table carries restaurant_id, every query filters on it (taken from the authenticated staff member's JWT, never from client input), and a separate requireOwnership middleware re-verifies that a requested resource actually belongs to the caller's restaurant before the handler runs.
Why: Shared-schema multi-tenancy is operationally far simpler than per-tenant schemas or databases at this scale — one connection pool, one migration path, trivial cross-tenant analytics for the superadmin console. The ownership middleware exists specifically so that if a query is ever mis-scoped, the request still 403s rather than leaking another restaurant's data.
Trade-off: Every new table and query has to remember to scope by restaurant_id — a single missed filter is a tenant-isolation bug. The ownership middleware is the safety net for exactly that human-error case.
4. Server-snapshotted pricing inside a DB transaction
Decision: POST /orders never trusts client-supplied prices, modifier costs, or discounts. It re-fetches each menu item and modifier from the database inside a transaction, computes the subtotal, resolves the best applicable discount, calculates GST (split into CGST/SGST where required), and only then commits — all before emitting any socket event.
Why: This is the financial core of the product; a race condition or a trusted client value here is a direct path to revenue loss or tax-reporting errors. Wrapping it in a transaction also guarantees the kitchen never sees an order that the database doesn't durably have.
Trade-off: Slightly higher latency on order placement (multiple round-trips inside one transaction) in exchange for correctness guarantees that can't be bypassed from the client.
5. Two-tier cancellation model
Decision: Items still pending/confirmed can be cancelled instantly and for free by the customer. Items already preparing/ready instead create a cancellation_request that the kitchen must explicitly approve or reject — optionally waiving or applying a cancellation charge — and the order's totals are recalculated transactionally either way.
Why: A flat "always allow free cancellation" policy is open to abuse (cancel after the kitchen has already cooked the food); a flat "no cancellation" policy is bad for customers who misordered. Splitting the policy by where the item already is in the kitchen workflow aligns incentives for both sides.
Trade-off: Meaningfully more state to track (cancellation_requests, charge fields, replacement links) than a single cancel path — but it directly reflects how a real kitchen actually operates.
6. JWT with a server-side revocation list
Decision: Tokens are stateless JWTs (7-day expiry, payload includes a unique jti), but logout and password-reset additionally write that jti to a blacklist that authenticate checks on every request.
Why: Pure stateless JWTs can't be invalidated before they expire — a real problem for "log out everywhere" and "I think my password leaked" scenarios. A blacklist keyed on a short random ID (rather than tracking full sessions) keeps the lookup cheap while closing that gap.
Trade-off: Adds one extra check (and a cleanup job for expired entries) to every authenticated request — a small, deliberate cost for the ability to actually revoke access on demand.
item_modifiers, table_merges, discounts, cancellation_requests, login_attempts, and token_blacklist all have composite indexes matching their actual query patterns (e.g. (restaurant_id, is_active), (email, attempted_at))pg.Pool, shared across REST handlers, the socket layer, and background jobsThe project relies on a tight, fast iteration loop (115 commits across roughly two and a half months of active development) backed by a seeded demo tenant — Spice Garden — that mirrors production data shape (tables, categories, menu items, staff roles) closely enough to exercise every flow, from QR scan through kitchen approval to GST reporting, before it ships.
io.emit.Status: In production. Live at eatlo.in, actively onboarding paying restaurants in Hyderabad, with continuous weekly shipping (115 commits since the initial commit).
Recently shipped: thermal KOT/bill printing with urgency timers and audio alerts, animated landing-page feature demos, an audited and hardened cancellation/replacement system, and live payment-request syncing on the admin dashboard.
Planned: