Architecture Overview
Tina Diet has two user-facing surfaces (LINE bot chat + LIFF web app) sharing one backend, with SQLite for state and three external services for AI, payment, and event delivery.
System diagram
┌──────────────────┐ │ OpenAI API │ │ gpt-4o, │ │ gpt-4o-mini │ └─────────▲────────┘ │ ┌─────────────────┐ ┌────────────────┴────────────────┐ │ LINE Messaging │ │ │ │ API (Cloud) │◀──▶│ │ └────────▲────────┘ │ Railway backend │ │ │ api.tinadiet.com │ │webhook │ │ ┌────────┴────────┐ │ ┌──────────────────────────┐ │ │ User on LINE │ │ │ Express │ │ │ (Thai phone) │ │ │ /webhook/line │ │ └────────┬────────┘ │ │ /webhooks/omise │ │ │ │ │ /api/v1/* │ │ ┌────────▼────────┐ │ │ /internal/jobs/* │ │ │ LIFF app │ │ └────────┬─────────────────┘ │ │ app.tinadiet.com│───▶│ │ │ │ (Cloudflare │ │ ┌────────▼────────┐ │ │ Workers) │ │ │ better-sqlite3 │ │ └─────────────────┘ │ │ /data/app.db │ │ │ │ (volume) │ │ │ └─────────────────┘ │ └────────────────▲────────────────┘ │webhook ┌─────────┴────────┐ │ Omise API │ │ PromptPay + │ │ TrueMoney │ └──────────────────┘Components
LINE bot chat surface
- User sends a message in LINE → LINE webhook →
POST /webhook/lineon backend - Backend signature-verifies, classifies intent (greeting / show-logs / log-weight / consult / parse), runs handler, replies via LINE Reply API
- Push messages: backend → LINE Push API for daily/weekly summaries, renewal reminders, welcome message
LIFF (LINE Front-end Framework) app
- A React SPA hosted at
app.tinadiet.com(Cloudflare Workers Static Assets) - Loaded by LINE app’s embedded webview, gets the user’s
lineUserIdvia LIFF SDK - Exchanges
lineUserIdfor a backend session JWT at/api/v1/auth/exchange - All subsequent API calls authenticate with
Authorization: Bearer <jwt>
Backend (Railway)
- Single Express process, Node 22 ESM,
tsx watchfor dev - Mounted routers in order (order matters for express.json body parser):
/webhook/line— express.json (after sig verify)/webhooks/stripe— express.raw (dormant)/webhooks/omise— express.raw (for HMAC sig verify)- CORS for
/api/v1 express.jsonglobal/api/v1/*— all LIFF-facing API/internal/jobs/*— cron triggers (x-jobs-secret guarded)/healthz— public health check
- Cron jobs (node-cron, Asia/Bangkok timezone):
0 21 * * *daily summary0 8 * * 1weekly summary (Monday morning)0 2 * * *expire premium
SQLite database
- File at
/data/app.dbon Railway volume (backend-volume, 1 GB) - Accessed synchronously via
better-sqlite3— no async/await for queries - Migrations applied at boot via
runMigrations()(idempotent) - See Data model for schema
External services
- OpenAI — text parsing (food logs from chat), vision parsing (food photos), coach suggestions, consultation Q&A
- Omise — PromptPay QR + TrueMoney Wallet charges + webhooks
- LINE Cloud — Messaging API (bot) + Login API (LIFF auth)
Data flow: AI food log via chat
- User sends “ผัดกะเพราไก่ + ไข่ดาว” in LINE
- LINE → POST /webhook/line (signed request)
- Backend verifies signature, parses event, classifies intent →
attempt_parse - Backend calls OpenAI
gpt-4o-miniwith system prompt + user text + strict JSON schema - AI returns
{ items: [{food_name_th: 'ผัดกะเพราไก่', kcal: 450, ...}, {food_name_th: 'ไข่ดาว', kcal: 90, ...}] }(orneeds_clarification/not_food) - Backend inserts each item into
food_logstable - Backend asks for proactive meal suggestion via
coach.ts(also OpenAI) - Backend replies to LINE with confirmation + suggestion in single message
Data flow: payment (Omise PromptPay)
- User taps “Premium” in LIFF Rich Menu → PremiumSection
- User picks PromptPay, taps “ชำระ 150 ฿”
- LIFF POST
/api/v1/billing/omise/chargebody{method:"promptpay"} - Backend calls Omise API
POST /chargeswith source.type=promptpay - Omise creates charge, returns
qr_image_uri - Backend inserts
paymentsrow (status=pending), returns charge to LIFF - LIFF shows QR modal with countdown + polls
GET /omise/charge/:idevery 2 seconds - Meanwhile Omise fires
charge.createwebhook → backend ACKs (no grant) - User scans QR in bank app, pays
- Omise fires
charge.completewebhook with HMAC signature - Backend verifies signature, marks payment successful, extends
users.premium_expires_at(stacks if user was already premium) - LIFF polling sees new status → modal transitions to success → reload billing status
Where things live
| Concept | Path |
|---|---|
| Database schema | backend/src/db/migrations.ts |
| Repositories | backend/src/repositories/ |
| HTTP routes | backend/src/routes/{api,webhook,internal}/ |
| Domain logic | backend/src/domain/ |
| AI services | backend/src/services/{food_parser,coach,consultation}.ts |
| Payment service | backend/src/services/{omise,stripe}.ts |
| Background jobs | backend/src/jobs/ |
| LIFF pages | liff/src/pages/ |
| LIFF components | liff/src/components/ |
| LIFF API client | liff/src/api/ |
| Brand assets | liff/public/ |
Read next
- Data model — every table
- Key invariants — patterns that must not be broken
- Backend stack — Express conventions
- LIFF stack — React conventions