Shaikhsarib commited on
Commit
a17af42
·
verified ·
1 Parent(s): 17b93cf

Upload 5 files

Browse files
Files changed (5) hide show
  1. auth.py +104 -0
  2. conftest.py +4 -0
  3. db.py +290 -0
  4. main.py +723 -0
  5. payments.py +88 -0
auth.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/routes/auth.py
3
+ Auth endpoints: OTP request, OTP verify, logout, profile.
4
+ Wraps app/services/auth.py logic.
5
+ """
6
+ import logging
7
+ from fastapi import APIRouter, Request, Form, HTTPException
8
+ from fastapi.responses import JSONResponse
9
+ from app.services.auth import (
10
+ send_email_otp, verify_email_otp,
11
+ create_session, revoke_session, get_user_from_token,
12
+ )
13
+ from app.models.db import db_conn
14
+
15
+ logger = logging.getLogger(__name__)
16
+ router = APIRouter(prefix="/auth", tags=["auth"])
17
+
18
+
19
+ @router.post("/request-otp")
20
+ async def request_otp(email: str = Form(...)):
21
+ """Send a 6-digit OTP to the user's email. Dev mode: returns OTP in response."""
22
+ try:
23
+ otp = send_email_otp(email)
24
+ return JSONResponse({
25
+ "sent" : True,
26
+ "message": "OTP sent. Check your email.",
27
+ # Remove next line in production — only for dev/HF testing
28
+ "_dev_otp": otp,
29
+ })
30
+ except Exception as exc:
31
+ logger.error("OTP request failed: %s", exc)
32
+ raise HTTPException(status_code=500, detail="Could not send OTP")
33
+
34
+
35
+ @router.post("/verify-otp")
36
+ async def verify_otp(
37
+ request: Request,
38
+ email: str = Form(...),
39
+ otp : str = Form(...),
40
+ ):
41
+ """Verify OTP. Returns session token on success."""
42
+ user = verify_email_otp(email, otp)
43
+ if not user:
44
+ raise HTTPException(status_code=401, detail="Invalid or expired OTP")
45
+ device_hint = request.headers.get("user-agent", "")[:100]
46
+ token = create_session(user["id"], device_hint)
47
+ return JSONResponse({
48
+ "token" : token,
49
+ "user_id" : user["id"],
50
+ "email" : user.get("email", ""),
51
+ "is_pro" : bool(user.get("is_pro", 0)),
52
+ "message" : "Login successful",
53
+ })
54
+
55
+
56
+ @router.post("/logout")
57
+ async def logout(request: Request):
58
+ """Revoke the current session token."""
59
+ auth = request.headers.get("Authorization", "")
60
+ token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
61
+ if token:
62
+ revoke_session(token)
63
+ return JSONResponse({"logged_out": True})
64
+
65
+
66
+ @router.get("/me")
67
+ async def get_me(request: Request):
68
+ """Return current user profile from Bearer token."""
69
+ auth = request.headers.get("Authorization", "")
70
+ token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
71
+ user = get_user_from_token(token) if token else None
72
+ if not user:
73
+ raise HTTPException(status_code=401, detail="Not authenticated")
74
+ return JSONResponse({
75
+ "user_id" : user["id"],
76
+ "email" : user.get("email", ""),
77
+ "name" : user.get("name", ""),
78
+ "is_pro" : bool(user.get("is_pro", 0)),
79
+ "streak_days": user.get("streak_days", 0),
80
+ "persona" : user.get("persona", "General Adult"),
81
+ "language" : user.get("language", "en"),
82
+ })
83
+
84
+
85
+ @router.put("/profile")
86
+ async def update_profile(
87
+ request : Request,
88
+ name : str = Form(""),
89
+ persona : str = Form("General Adult"),
90
+ language: str = Form("en"),
91
+ tdee : float = Form(2000),
92
+ ):
93
+ """Update user profile fields."""
94
+ auth = request.headers.get("Authorization", "")
95
+ token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
96
+ user = get_user_from_token(token) if token else None
97
+ if not user:
98
+ raise HTTPException(status_code=401, detail="Not authenticated")
99
+ with db_conn() as conn:
100
+ conn.execute(
101
+ "UPDATE users SET name=?, persona=?, language=?, tdee=? WHERE id=?",
102
+ (name, persona, language, tdee, user["id"])
103
+ )
104
+ return JSONResponse({"updated": True})
conftest.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ # Ensure project root is on path so `from app.xxx` works in tests
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
db.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/models/db.py
3
+ Database schema, connection management, and initialisation.
4
+ Uses SQLite with WAL mode for single-server MVP.
5
+ Migration path: swap sqlite3 for asyncpg/SQLAlchemy when ≥100 DAU.
6
+ """
7
+ import os
8
+ import json
9
+ import sqlite3
10
+ import threading
11
+ import logging
12
+ from contextlib import contextmanager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ DATA_DIR = os.path.join(os.getcwd(), "data")
17
+ DB_FILE = os.path.join(DATA_DIR, "eatlytic.db")
18
+ os.makedirs(DATA_DIR, exist_ok=True)
19
+
20
+
21
+ def get_connection() -> sqlite3.Connection:
22
+ conn = sqlite3.connect(DB_FILE, check_same_thread=False, timeout=15)
23
+ conn.row_factory = sqlite3.Row
24
+ conn.execute("PRAGMA journal_mode=WAL")
25
+ conn.execute("PRAGMA foreign_keys=ON")
26
+ conn.execute("PRAGMA synchronous=NORMAL") # fast + safe in WAL mode
27
+ return conn
28
+
29
+
30
+ @contextmanager
31
+ def db_conn():
32
+ """Thread-safe context manager: auto-commit on success, rollback on error."""
33
+ conn = get_connection()
34
+ try:
35
+ yield conn
36
+ conn.commit()
37
+ except Exception:
38
+ conn.rollback()
39
+ raise
40
+ finally:
41
+ conn.close()
42
+
43
+
44
+ def init_db() -> None:
45
+ """Idempotent schema creation. Run at startup."""
46
+ with db_conn() as conn:
47
+ conn.executescript("""
48
+ -- ── USERS (Phase 1: real accounts) ───────────────────────
49
+ CREATE TABLE IF NOT EXISTS users (
50
+ id TEXT PRIMARY KEY, -- UUID
51
+ email TEXT UNIQUE,
52
+ phone TEXT UNIQUE,
53
+ name TEXT DEFAULT '',
54
+ created_at TEXT DEFAULT (datetime('now')),
55
+ last_login TEXT DEFAULT (datetime('now')),
56
+ is_pro INTEGER DEFAULT 0,
57
+ pro_expires TEXT DEFAULT NULL,
58
+ stripe_customer_id TEXT DEFAULT NULL, -- Razorpay customer
59
+ scan_count_month INTEGER DEFAULT 0,
60
+ scan_month TEXT DEFAULT '',
61
+ streak_days INTEGER DEFAULT 0,
62
+ last_scan_date TEXT DEFAULT '',
63
+ tdee REAL DEFAULT 0,
64
+ persona TEXT DEFAULT 'General Adult',
65
+ language TEXT DEFAULT 'en',
66
+ onboarding_done INTEGER DEFAULT 0
67
+ );
68
+
69
+ -- ── SESSIONS / TOKENS ─────────────────────────────────────
70
+ CREATE TABLE IF NOT EXISTS sessions (
71
+ token TEXT PRIMARY KEY,
72
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
73
+ created_at TEXT DEFAULT (datetime('now')),
74
+ expires_at TEXT NOT NULL,
75
+ device_hint TEXT DEFAULT ''
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
78
+
79
+ -- ── LEGACY DEVICE KEYS (for anonymous users) ──────────────
80
+ CREATE TABLE IF NOT EXISTS devices (
81
+ device_key TEXT PRIMARY KEY,
82
+ user_id TEXT REFERENCES users(id), -- NULL = anonymous
83
+ created_at TEXT DEFAULT (datetime('now')),
84
+ is_pro INTEGER DEFAULT 0,
85
+ month TEXT DEFAULT '',
86
+ scan_count INTEGER DEFAULT 0,
87
+ streak_days INTEGER DEFAULT 0,
88
+ last_scan_date TEXT DEFAULT '',
89
+ persona TEXT DEFAULT 'General Adult',
90
+ language TEXT DEFAULT 'en',
91
+ tdee REAL DEFAULT 0,
92
+ onboarding_done INTEGER DEFAULT 0
93
+ );
94
+
95
+ -- ── SCANS ─────────────────────────────────────────────────
96
+ CREATE TABLE IF NOT EXISTS scans (
97
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
98
+ user_id TEXT REFERENCES users(id),
99
+ device_key TEXT,
100
+ product_name TEXT DEFAULT 'Unknown',
101
+ score INTEGER DEFAULT 0,
102
+ verdict TEXT DEFAULT '',
103
+ calories REAL DEFAULT 0,
104
+ protein REAL DEFAULT 0,
105
+ carbs REAL DEFAULT 0,
106
+ fat REAL DEFAULT 0,
107
+ sodium REAL DEFAULT 0,
108
+ fiber REAL DEFAULT 0,
109
+ sugar REAL DEFAULT 0,
110
+ persona TEXT DEFAULT '',
111
+ language TEXT DEFAULT 'en',
112
+ scanned_at TEXT DEFAULT (datetime('now')),
113
+ analysis_json TEXT DEFAULT '{}',
114
+ -- Moat columns: verified data feeds proprietary DB
115
+ verified INTEGER DEFAULT 0,
116
+ verified_by TEXT DEFAULT NULL,
117
+ verified_at TEXT DEFAULT NULL,
118
+ barcode TEXT DEFAULT NULL,
119
+ brand TEXT DEFAULT NULL,
120
+ category TEXT DEFAULT NULL
121
+ );
122
+ CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id);
123
+ CREATE INDEX IF NOT EXISTS idx_scans_device ON scans(device_key);
124
+ CREATE INDEX IF NOT EXISTS idx_scans_date ON scans(scanned_at);
125
+ CREATE INDEX IF NOT EXISTS idx_scans_product ON scans(product_name);
126
+
127
+ -- ── DAILY LOGS ────────────────────────────────────────────
128
+ CREATE TABLE IF NOT EXISTS daily_logs (
129
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
130
+ user_id TEXT REFERENCES users(id),
131
+ device_key TEXT,
132
+ log_date TEXT NOT NULL,
133
+ meal_name TEXT DEFAULT '',
134
+ calories REAL DEFAULT 0,
135
+ protein REAL DEFAULT 0,
136
+ carbs REAL DEFAULT 0,
137
+ fat REAL DEFAULT 0,
138
+ sodium REAL DEFAULT 0,
139
+ fiber REAL DEFAULT 0,
140
+ sugar REAL DEFAULT 0,
141
+ source TEXT DEFAULT 'scan', -- scan | manual | search
142
+ logged_at TEXT DEFAULT (datetime('now'))
143
+ );
144
+ CREATE INDEX IF NOT EXISTS idx_daily_user_date ON daily_logs(user_id, log_date);
145
+ CREATE INDEX IF NOT EXISTS idx_daily_dev_date ON daily_logs(device_key, log_date);
146
+
147
+ -- ── ALLERGEN PROFILES ─────────────────────────────────────
148
+ CREATE TABLE IF NOT EXISTS allergen_profiles (
149
+ device_key TEXT PRIMARY KEY,
150
+ user_id TEXT REFERENCES users(id),
151
+ allergens TEXT DEFAULT '[]',
152
+ conditions TEXT DEFAULT '[]',
153
+ updated_at TEXT DEFAULT (datetime('now'))
154
+ );
155
+
156
+ -- ── PROPRIETARY FOOD DATABASE (Phase 2 moat) ──────────────
157
+ -- Every scan feeds this. After 10K entries, it's a data asset.
158
+ CREATE TABLE IF NOT EXISTS food_products (
159
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
160
+ barcode TEXT UNIQUE, -- EAN-13 if available
161
+ name TEXT NOT NULL,
162
+ brand TEXT DEFAULT '',
163
+ category TEXT DEFAULT '',
164
+ -- Verified nutrition per 100g
165
+ calories_100g REAL DEFAULT 0,
166
+ protein_100g REAL DEFAULT 0,
167
+ carbs_100g REAL DEFAULT 0,
168
+ fat_100g REAL DEFAULT 0,
169
+ sodium_100g REAL DEFAULT 0,
170
+ fiber_100g REAL DEFAULT 0,
171
+ sugar_100g REAL DEFAULT 0,
172
+ sat_fat_100g REAL DEFAULT 0,
173
+ -- Eatlytic scoring
174
+ eatlytic_score INTEGER DEFAULT 0,
175
+ fssai_compliant INTEGER DEFAULT 0,
176
+ ingredients_raw TEXT DEFAULT '',
177
+ allergens_json TEXT DEFAULT '[]',
178
+ -- Data provenance
179
+ source TEXT DEFAULT 'llm_scan', -- llm_scan | human_verified | off_import
180
+ scan_count INTEGER DEFAULT 0, -- how many times scanned
181
+ verified INTEGER DEFAULT 0,
182
+ verified_by TEXT DEFAULT NULL,
183
+ created_at TEXT DEFAULT (datetime('now')),
184
+ updated_at TEXT DEFAULT (datetime('now'))
185
+ );
186
+ CREATE INDEX IF NOT EXISTS idx_food_barcode ON food_products(barcode);
187
+ CREATE INDEX IF NOT EXISTS idx_food_name ON food_products(name);
188
+ CREATE INDEX IF NOT EXISTS idx_food_brand ON food_products(brand);
189
+
190
+ -- ── ACCURACY BENCHMARKS (Phase 2) ─────────────────────────
191
+ CREATE TABLE IF NOT EXISTS benchmarks (
192
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
193
+ product_name TEXT NOT NULL,
194
+ ground_truth_json TEXT NOT NULL, -- hand-verified nutrition data
195
+ llm_output_json TEXT DEFAULT '{}',
196
+ ocr_text TEXT DEFAULT '',
197
+ f1_score REAL DEFAULT 0,
198
+ score_delta REAL DEFAULT 0, -- LLM score vs verified score
199
+ field_accuracy TEXT DEFAULT '{}', -- per-field accuracy JSON
200
+ tested_at TEXT DEFAULT (datetime('now')),
201
+ model_used TEXT DEFAULT ''
202
+ );
203
+
204
+ -- ── NPS ───────────────────────────────────────────────────
205
+ CREATE TABLE IF NOT EXISTS nps_responses (
206
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
207
+ device_key TEXT,
208
+ user_id TEXT REFERENCES users(id),
209
+ score INTEGER NOT NULL,
210
+ comment TEXT DEFAULT '',
211
+ submitted_at TEXT DEFAULT (datetime('now'))
212
+ );
213
+
214
+ -- ── PAYMENTS ──────────────────────────────────────────────
215
+ CREATE TABLE IF NOT EXISTS payments (
216
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
217
+ user_id TEXT REFERENCES users(id),
218
+ device_key TEXT,
219
+ razorpay_order_id TEXT UNIQUE,
220
+ razorpay_payment_id TEXT UNIQUE,
221
+ razorpay_signature TEXT DEFAULT '',
222
+ amount_paise INTEGER DEFAULT 19900, -- ₹199
223
+ currency TEXT DEFAULT 'INR',
224
+ status TEXT DEFAULT 'created', -- created|paid|failed
225
+ plan TEXT DEFAULT 'pro_monthly',
226
+ created_at TEXT DEFAULT (datetime('now')),
227
+ paid_at TEXT DEFAULT NULL
228
+ );
229
+
230
+ -- ── B2B API KEYS ──────────────────────────────────────────
231
+ CREATE TABLE IF NOT EXISTS api_keys (
232
+ api_key TEXT PRIMARY KEY,
233
+ client_name TEXT NOT NULL,
234
+ plan TEXT DEFAULT 'business',
235
+ scans_this_month INTEGER DEFAULT 0,
236
+ month TEXT DEFAULT '',
237
+ active INTEGER DEFAULT 1,
238
+ created_at TEXT DEFAULT (datetime('now'))
239
+ );
240
+
241
+ -- ── CACHES ────────────────────────────────────────────────
242
+ CREATE TABLE IF NOT EXISTS ocr_cache (
243
+ cache_key TEXT PRIMARY KEY,
244
+ result_json TEXT NOT NULL,
245
+ created_at TEXT DEFAULT (datetime('now'))
246
+ );
247
+ CREATE TABLE IF NOT EXISTS ai_cache (
248
+ cache_key TEXT PRIMARY KEY,
249
+ result_json TEXT NOT NULL,
250
+ created_at TEXT DEFAULT (datetime('now'))
251
+ );
252
+ """)
253
+ logger.info("Database ready: %s", DB_FILE)
254
+
255
+
256
+ # ── Cache helpers ──────────────────────────────────────────────────────
257
+ def get_ocr_cache(key: str):
258
+ try:
259
+ with db_conn() as c:
260
+ row = c.execute("SELECT result_json FROM ocr_cache WHERE cache_key=?", (key,)).fetchone()
261
+ return json.loads(row["result_json"]) if row else None
262
+ except Exception:
263
+ return None
264
+
265
+
266
+ def set_ocr_cache(key: str, value: dict):
267
+ try:
268
+ with db_conn() as c:
269
+ c.execute("INSERT OR REPLACE INTO ocr_cache(cache_key,result_json) VALUES(?,?)",
270
+ (key, json.dumps(value)))
271
+ except Exception as exc:
272
+ logger.warning("set_ocr_cache: %s", exc)
273
+
274
+
275
+ def get_ai_cache(key: str):
276
+ try:
277
+ with db_conn() as c:
278
+ row = c.execute("SELECT result_json FROM ai_cache WHERE cache_key=?", (key,)).fetchone()
279
+ return json.loads(row["result_json"]) if row else None
280
+ except Exception:
281
+ return None
282
+
283
+
284
+ def set_ai_cache(key: str, value: dict):
285
+ try:
286
+ with db_conn() as c:
287
+ c.execute("INSERT OR REPLACE INTO ai_cache(cache_key,result_json) VALUES(?,?)",
288
+ (key, json.dumps(value)))
289
+ except Exception as exc:
290
+ logger.warning("set_ai_cache: %s", exc)
main.py ADDED
@@ -0,0 +1,723 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Eatlytic v4 — Modular FastAPI Application
3
+ =========================================
4
+ Architecture:
5
+ app/models/db.py — SQLite schema, connection, caching
6
+ app/services/auth.py — User accounts, OTP, sessions
7
+ app/services/payments.py— Razorpay integration (real payments)
8
+ app/services/image.py — Blur detection + Wiener deblurring pipeline
9
+ app/services/ocr.py — EasyOCR wrapper, label detection
10
+ app/services/llm.py — LLM abstraction, food DB population
11
+ app/routes/auth.py — /auth/* endpoints
12
+ app/routes/payments.py — /payments/* endpoints
13
+ app/routes/food_db.py — /food-db/* endpoints (Phase 2 moat)
14
+ app/routes/benchmarks.py— /benchmarks/* endpoints (accuracy system)
15
+ main.py — app factory, legacy routes, startup
16
+
17
+ Run: uvicorn main:app --host 0.0.0.0 --port 7860
18
+ Test: pytest tests/ -v
19
+ """
20
+
21
+ import os
22
+ import re
23
+ import json
24
+ import asyncio
25
+ import logging
26
+ import hashlib
27
+ import datetime
28
+ import secrets
29
+ from io import BytesIO
30
+
31
+ from fastapi import FastAPI, File, UploadFile, Form, Request, HTTPException, Security
32
+ from fastapi.middleware.cors import CORSMiddleware
33
+ from fastapi.responses import FileResponse, JSONResponse, Response
34
+ from fastapi.security import APIKeyHeader
35
+ from slowapi import Limiter, _rate_limit_exceeded_handler
36
+ from slowapi.util import get_remote_address
37
+ from slowapi.errors import RateLimitExceeded
38
+
39
+ # ── DuckDuckGo: guarded import ─────────────────────────────────────────
40
+ try:
41
+ from duckduckgo_search import DDGS as _DDGS; _DDGS_OK = True
42
+ except Exception:
43
+ _DDGS = None; _DDGS_OK = False
44
+
45
+ logging.basicConfig(level=logging.INFO)
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # ── App factory ────────────────────────────────────────────────────────
49
+ limiter = Limiter(key_func=get_remote_address)
50
+ app = FastAPI(title="Eatlytic v4 — Food Intelligence", version="4.0")
51
+ app.state.limiter = limiter
52
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
53
+
54
+ _ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
55
+ app.add_middleware(
56
+ CORSMiddleware,
57
+ allow_origins = [_ALLOWED_ORIGIN],
58
+ allow_methods = ["GET", "POST", "DELETE", "PATCH"],
59
+ allow_headers = ["*"],
60
+ allow_credentials = _ALLOWED_ORIGIN != "*",
61
+ )
62
+
63
+ # ── DB init at startup ─────────────────────────────────────────────────
64
+ from app.models.db import init_db, db_conn
65
+ init_db()
66
+
67
+ # ── Import + mount modular routers ────────────────────────────────────
68
+ from app.routes.auth import router as auth_router
69
+ from app.routes.payments import router as payments_router
70
+ from app.routes.food_db import router as food_db_router
71
+ from app.routes.benchmarks import router as benchmarks_router
72
+
73
+ app.include_router(auth_router)
74
+ app.include_router(payments_router)
75
+ app.include_router(food_db_router)
76
+ app.include_router(benchmarks_router)
77
+
78
+ # ── Import services ────────────────────────────────────────────────────
79
+ from app.services.image import (
80
+ validate_image, assess_image_quality, deblur_and_enhance,
81
+ image_to_b64, ocr_quality_score,
82
+ )
83
+ from app.services.ocr import run_ocr, detect_label_presence
84
+ from app.services.llm import (
85
+ analyse_label, upsert_food_product, MEDICAL_DISCLAIMER, LANGUAGE_MAP
86
+ )
87
+ from app.services.auth import (
88
+ get_user_from_token, check_and_increment_scan_user,
89
+ update_streak_user,
90
+ )
91
+
92
+ FREE_SCAN_LIMIT = int(os.environ.get("FREE_SCAN_LIMIT", "10"))
93
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
94
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
95
+
96
+
97
+ # ── Auth helpers ───────────────────────────────────────────────────────
98
+ def _get_request_user(request: Request):
99
+ """Extract authenticated user from Bearer token (optional)."""
100
+ auth = request.headers.get("Authorization", "")
101
+ token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
102
+ return get_user_from_token(token) if token else None
103
+
104
+
105
+ def _device_key(request: Request) -> str:
106
+ ip = request.client.host if request.client else "unknown"
107
+ ua = request.headers.get("user-agent", "")
108
+ return hashlib.md5(f"{ip}:{ua}".encode()).hexdigest()[:16]
109
+
110
+
111
+ def _ensure_device(device_key: str):
112
+ try:
113
+ with db_conn() as conn:
114
+ conn.execute("INSERT OR IGNORE INTO devices(device_key) VALUES(?)", (device_key,))
115
+ except Exception:
116
+ pass
117
+
118
+
119
+ def _check_scan_quota(user, device_key: str) -> dict:
120
+ """Check quota for authenticated user; fall back to device for anonymous."""
121
+ if user:
122
+ return check_and_increment_scan_user(user["id"])
123
+ # Legacy anonymous path
124
+ month_key = datetime.date.today().isoformat()[:7]
125
+ _ensure_device(device_key)
126
+ with db_conn() as conn:
127
+ row = conn.execute(
128
+ "SELECT is_pro, month, scan_count FROM devices WHERE device_key=?", (device_key,)
129
+ ).fetchone()
130
+ if not row:
131
+ return {"allowed": False, "scans_used": 0, "scans_remaining": 0, "is_pro": False}
132
+ if row["month"] != month_key:
133
+ conn.execute("UPDATE devices SET month=?, scan_count=0 WHERE device_key=?",
134
+ (month_key, device_key))
135
+ count = 0
136
+ else:
137
+ count = row["scan_count"]
138
+ if row["is_pro"]:
139
+ conn.execute("UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?", (device_key,))
140
+ return {"allowed": True, "scans_used": count+1, "scans_remaining": 9999, "is_pro": True}
141
+ if count >= FREE_SCAN_LIMIT:
142
+ return {"allowed": False, "scans_used": count, "scans_remaining": 0, "is_pro": False}
143
+ conn.execute("UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?", (device_key,))
144
+ new = count + 1
145
+ return {"allowed": True, "scans_used": new,
146
+ "scans_remaining": FREE_SCAN_LIMIT - new, "is_pro": False}
147
+
148
+
149
+ def _get_live_search(query: str) -> str:
150
+ if not _DDGS_OK:
151
+ return "Web search unavailable."
152
+ try:
153
+ with _DDGS() as ddgs:
154
+ results = [f"{r['title']}: {r['body']}" for r in ddgs.text(query, max_results=3)]
155
+ return "\n".join(results) if results else "No web data."
156
+ except Exception as exc:
157
+ logger.warning("Web search: %s", exc)
158
+ return "No web data."
159
+
160
+
161
+ def _verify_api_key(api_key: str = Security(api_key_header)):
162
+ if not api_key:
163
+ return None
164
+ with db_conn() as conn:
165
+ row = conn.execute(
166
+ "SELECT * FROM api_keys WHERE api_key=? AND active=1", (api_key,)
167
+ ).fetchone()
168
+ return dict(row) if row else None
169
+
170
+
171
+ # ══════════════════════════════════════════════════════════════════════
172
+ # CORE ROUTES
173
+ # ══════════════════════════════════════════════════════════════════════
174
+
175
+ @app.get("/")
176
+ async def home():
177
+ return FileResponse("index.html")
178
+
179
+
180
+ @app.get("/health")
181
+ async def health():
182
+ return {"status": "ok", "version": "4.0", "db": "sqlite-wal"}
183
+
184
+
185
+ @app.post("/check-image")
186
+ @limiter.limit("30/minute")
187
+ async def check_image(request: Request, image: UploadFile = File(...)):
188
+ content = await image.read()
189
+ content = validate_image(content)
190
+ return assess_image_quality(content)
191
+
192
+
193
+ @app.post("/enhance-preview")
194
+ @limiter.limit("20/minute")
195
+ async def enhance_preview(request: Request, image: UploadFile = File(...)):
196
+ content = await image.read()
197
+ content = validate_image(content)
198
+ quality = assess_image_quality(content)
199
+ if not quality["is_blurry"]:
200
+ return JSONResponse({"deblurred": False, "message": "Image already clear.", "quality": quality})
201
+ enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
202
+ return JSONResponse({"deblurred": True, "image_b64": image_to_b64(enhanced),
203
+ "method_log": method_log, "quality_before": quality})
204
+
205
+
206
+ @app.post("/ocr")
207
+ @limiter.limit("20/minute")
208
+ async def perform_ocr(request: Request, image: UploadFile = File(...),
209
+ language: str = Form("en")):
210
+ content = await image.read()
211
+ content = validate_image(content)
212
+ return run_ocr(content, language)
213
+
214
+
215
+ @app.post("/analyze")
216
+ @limiter.limit("15/minute")
217
+ async def analyze_product(
218
+ request : Request,
219
+ persona : str = Form(...),
220
+ age_group : str = Form("adult"),
221
+ product_category : str = Form("general"),
222
+ language : str = Form("en"),
223
+ extracted_text : str = Form(None),
224
+ image : UploadFile = File(...),
225
+ ):
226
+ if not GROQ_API_KEY:
227
+ return JSONResponse({"error": "Server error: GROQ_API_KEY not set"})
228
+
229
+ user = _get_request_user(request)
230
+ device_key = _device_key(request)
231
+ scan_check = _check_scan_quota(user, device_key)
232
+
233
+ if not scan_check["allowed"]:
234
+ return JSONResponse(status_code=402, content={
235
+ "error" : "scan_limit_reached",
236
+ "message" : f"You've used all {FREE_SCAN_LIMIT} free scans this month.",
237
+ "upgrade_url": "/payments/create-order",
238
+ })
239
+
240
+ try:
241
+ content = await image.read()
242
+ content = validate_image(content) # ← 10MB limit enforcement
243
+ quality = assess_image_quality(content)
244
+ blur_info = {"detected": quality["is_blurry"], "severity": quality["blur_severity"],
245
+ "score": quality["blur_score"], "deblurred": False,
246
+ "method_log": None, "image_b64": None, "ocr_source": "original"}
247
+ working = content
248
+
249
+ if quality["is_blurry"]:
250
+ try:
251
+ enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
252
+ if (ocr_quality_score(run_ocr(enhanced, language)) >=
253
+ ocr_quality_score(run_ocr(content, language)) * 0.85):
254
+ working = enhanced
255
+ blur_info["deblurred"] = True
256
+ blur_info["method_log"] = method_log
257
+ blur_info["image_b64"] = image_to_b64(enhanced)
258
+ blur_info["ocr_source"] = "deblurred"
259
+ extracted_text = None
260
+ except Exception as exc:
261
+ logger.warning("Deblur failed: %s", exc)
262
+
263
+ if not extracted_text:
264
+ ocr_result = run_ocr(working, language)
265
+ extracted_text = ocr_result["text"]
266
+ ocr_word_count = ocr_result["word_count"]
267
+ else:
268
+ ocr_word_count = len(extracted_text.split())
269
+
270
+ if not extracted_text or ocr_word_count == 0:
271
+ return JSONResponse({"error": "no_text",
272
+ "message": "No text found. Make sure the label side is facing the camera.",
273
+ "tip": "flip_product"})
274
+
275
+ label_check = detect_label_presence(extracted_text)
276
+ if not label_check["has_label"]:
277
+ tip = label_check["suggestion"] or "flip_product"
278
+ msg = ("This looks like the front of the product. Flip it over and scan the back label."
279
+ if tip == "wrong_side" else "Could not find nutrition or ingredient information.")
280
+ return JSONResponse({"error": "no_label", "message": msg, "tip": tip,
281
+ "front_words_found": label_check.get("front_hits", [])})
282
+
283
+ # Allergen check
284
+ allergen_warning = ""
285
+ owner_id = user["id"] if user else None
286
+ try:
287
+ with db_conn() as conn:
288
+ row = conn.execute(
289
+ "SELECT allergens,conditions FROM allergen_profiles WHERE device_key=?",
290
+ (device_key,)
291
+ ).fetchone()
292
+ if row:
293
+ tl = extracted_text.lower()
294
+ triggered = [a for a in json.loads(row["allergens"] or "[]") if a.lower() in tl] + \
295
+ [c for c in json.loads(row["conditions"] or "[]") if c.lower() in tl]
296
+ if triggered:
297
+ allergen_warning = f"⚠️ ALLERGEN ALERT — may contain: {', '.join(triggered)}"
298
+ except Exception as exc:
299
+ logger.warning("Allergen check: %s", exc)
300
+
301
+ # Web search (non-blocking)
302
+ web_context = await asyncio.to_thread(
303
+ _get_live_search, f"health analysis ingredients {extracted_text[:120]}")
304
+
305
+ # LLM analysis
306
+ result = await analyse_label(
307
+ extracted_text, persona, age_group, product_category,
308
+ language, web_context, blur_info, label_check.get("confidence", "medium")
309
+ )
310
+ result["allergen_warning"] = allergen_warning
311
+ result["blur_info"] = blur_info
312
+ result["scan_meta"] = scan_check
313
+
314
+ # Auto-log to daily tracker
315
+ today = datetime.date.today().isoformat()
316
+ nutr = {n["name"].lower(): float(n.get("value", 0))
317
+ for n in result.get("nutrient_breakdown", [])
318
+ if isinstance(n.get("value"), (int, float))}
319
+ cal = nutr.get("energy", nutr.get("calories", nutr.get("calorie", 0)))
320
+ prot = nutr.get("protein", 0)
321
+ carb = nutr.get("carbohydrate", nutr.get("carbs", 0))
322
+ fat = nutr.get("fat", 0)
323
+ sod = nutr.get("sodium", 0)
324
+ fib = nutr.get("fiber", nutr.get("fibre", 0))
325
+ sug = nutr.get("sugar", nutr.get("sugars", 0))
326
+
327
+ with db_conn() as conn:
328
+ conn.execute(
329
+ """INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,
330
+ calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
331
+ (owner_id, device_key, today, result.get("product_name","Scanned item"),
332
+ cal, prot, carb, fat, sod, fib, sug, "scan")
333
+ )
334
+ conn.execute(
335
+ """INSERT INTO scans(user_id,device_key,product_name,score,verdict,
336
+ calories,protein,carbs,fat,sodium,fiber,sugar,persona,language,analysis_json)
337
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
338
+ (owner_id, device_key, result.get("product_name","Unknown"),
339
+ result.get("score", 0), result.get("verdict", ""),
340
+ cal, prot, carb, fat, sod, fib, sug, persona, language,
341
+ json.dumps({k: v for k, v in result.items()
342
+ if k not in ("blur_info","scan_meta","allergen_warning")}))
343
+ )
344
+
345
+ # Phase 2: populate proprietary food database
346
+ try:
347
+ upsert_food_product(
348
+ name=result.get("product_name", ""),
349
+ nutrients=result.get("nutrient_breakdown", []),
350
+ score=result.get("score", 0),
351
+ ingredients_raw=extracted_text,
352
+ category=result.get("product_category", ""),
353
+ source="llm_scan",
354
+ )
355
+ except Exception as exc:
356
+ logger.warning("Food DB upsert: %s", exc)
357
+
358
+ # Update streak
359
+ if user:
360
+ update_streak_user(user["id"])
361
+ else:
362
+ # Legacy device streak
363
+ from app.services.auth import update_streak_user as _upd
364
+ try:
365
+ _ensure_device(device_key)
366
+ today_d = datetime.date.today().isoformat()
367
+ yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
368
+ with db_conn() as conn:
369
+ row = conn.execute(
370
+ "SELECT streak_days, last_scan_date FROM devices WHERE device_key=?",
371
+ (device_key,)
372
+ ).fetchone()
373
+ if row and row["last_scan_date"] != today_d:
374
+ streak = (row["streak_days"] + 1) if row["last_scan_date"] == yesterday else 1
375
+ conn.execute(
376
+ "UPDATE devices SET streak_days=?, last_scan_date=? WHERE device_key=?",
377
+ (streak, today_d, device_key)
378
+ )
379
+ except Exception:
380
+ pass
381
+
382
+ return JSONResponse(result)
383
+
384
+ except ValueError as exc:
385
+ return JSONResponse({"error": str(exc)}, status_code=400)
386
+ except Exception as exc:
387
+ logger.error("Analysis error: %s", exc, exc_info=True)
388
+ return JSONResponse({"error": f"Scan failed: {str(exc)[:140]}. Please try again."})
389
+
390
+
391
+ @app.get("/scan-status")
392
+ async def scan_status(request: Request):
393
+ user = _get_request_user(request)
394
+ device_key = _device_key(request)
395
+ _ensure_device(device_key)
396
+ month_key = datetime.date.today().isoformat()[:7]
397
+
398
+ if user:
399
+ from app.models.db import db_conn as _db
400
+ with _db() as conn:
401
+ row = conn.execute(
402
+ "SELECT is_pro, scan_month, scan_count_month, streak_days FROM users WHERE id=?",
403
+ (user["id"],)
404
+ ).fetchone()
405
+ if not row or row["scan_month"] != month_key:
406
+ return {"scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT,
407
+ "is_pro": False, "limit": FREE_SCAN_LIMIT, "streak": 0}
408
+ used = row["scan_count_month"]
409
+ return {"scans_used": used,
410
+ "scans_remaining": 9999 if row["is_pro"] else max(0, FREE_SCAN_LIMIT - used),
411
+ "is_pro": bool(row["is_pro"]), "limit": FREE_SCAN_LIMIT,
412
+ "streak": row["streak_days"],
413
+ "authenticated": True}
414
+
415
+ with db_conn() as conn:
416
+ row = conn.execute(
417
+ "SELECT is_pro, month, scan_count, streak_days FROM devices WHERE device_key=?",
418
+ (device_key,)
419
+ ).fetchone()
420
+ if not row or row["month"] != month_key:
421
+ return {"scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT,
422
+ "is_pro": False, "limit": FREE_SCAN_LIMIT, "streak": 0, "authenticated": False}
423
+ used = row["scan_count"]
424
+ return {"scans_used": used,
425
+ "scans_remaining": 9999 if row["is_pro"] else max(0, FREE_SCAN_LIMIT - used),
426
+ "is_pro": bool(row["is_pro"]), "limit": FREE_SCAN_LIMIT,
427
+ "streak": row["streak_days"], "authenticated": False}
428
+
429
+
430
+ @app.post("/activate-pro")
431
+ async def activate_pro_legacy(request: Request, payment_id: str = Form(...)):
432
+ """
433
+ LEGACY endpoint kept for backward compat.
434
+ Real payment flow: POST /payments/create-order → Razorpay modal → POST /payments/verify
435
+ """
436
+ if payment_id.startswith("demo_"):
437
+ logger.warning("DEMO payment ID used — no real money charged")
438
+ device_key = _device_key(request)
439
+ _ensure_device(device_key)
440
+ with db_conn() as conn:
441
+ conn.execute("UPDATE devices SET is_pro=1 WHERE device_key=?", (device_key,))
442
+ return {"status": "activated",
443
+ "message": "Pro activated. NOTE: Use /payments/create-order for real billing.",
444
+ "real_billing_endpoint": "/payments/create-order"}
445
+
446
+
447
+ @app.post("/onboarding-complete")
448
+ async def onboarding_complete(
449
+ request : Request,
450
+ persona : str = Form("General Adult"),
451
+ language : str = Form("en"),
452
+ tdee : float = Form(0),
453
+ allergens: str = Form("[]"),
454
+ ):
455
+ user = _get_request_user(request)
456
+ device_key = _device_key(request)
457
+ _ensure_device(device_key)
458
+ user_id = user["id"] if user else None
459
+ with db_conn() as conn:
460
+ conn.execute("UPDATE devices SET onboarding_done=1,persona=?,language=?,tdee=? WHERE device_key=?",
461
+ (persona, language, tdee, device_key))
462
+ if user_id:
463
+ conn.execute("UPDATE users SET onboarding_done=1,persona=?,language=?,tdee=? WHERE id=?",
464
+ (persona, language, tdee, user_id))
465
+ conn.execute(
466
+ "INSERT OR REPLACE INTO allergen_profiles(device_key,user_id,allergens) VALUES(?,?,?)",
467
+ (device_key, user_id, allergens)
468
+ )
469
+ return {"status": "ok"}
470
+
471
+
472
+ @app.get("/daily-summary")
473
+ async def daily_summary(request: Request, date: str = None):
474
+ user = _get_request_user(request)
475
+ device_key = _device_key(request)
476
+ target_date = date or datetime.date.today().isoformat()
477
+
478
+ user_id = user["id"] if user else None
479
+ with db_conn() as conn:
480
+ if user_id:
481
+ dev = conn.execute("SELECT tdee FROM users WHERE id=?", (user_id,)).fetchone()
482
+ else:
483
+ dev = conn.execute("SELECT tdee FROM devices WHERE device_key=?", (device_key,)).fetchone()
484
+
485
+ # Fetch from either user_id or device_key
486
+ clause = "user_id=?" if user_id else "device_key=?"
487
+ param = user_id or device_key
488
+ row = conn.execute(
489
+ f"""SELECT SUM(calories) cal, SUM(protein) prot, SUM(carbs) carb,
490
+ SUM(fat) fat, SUM(sodium) sod, SUM(fiber) fib, SUM(sugar) sug, COUNT(*) items
491
+ FROM daily_logs WHERE {clause} AND log_date=?""",
492
+ (param, target_date)
493
+ ).fetchone()
494
+ log_items = conn.execute(
495
+ f"""SELECT id, meal_name, calories, protein, carbs, fat, sodium, source, logged_at
496
+ FROM daily_logs WHERE {clause} AND log_date=? ORDER BY logged_at DESC""",
497
+ (param, target_date)
498
+ ).fetchall()
499
+
500
+ tdee = float((dev and dev["tdee"]) or 2000) or 2000
501
+ totals = {k: round(row[k] or 0, 1)
502
+ for k in ("cal","prot","carb","fat","sod","fib","sug")}
503
+ t = {"calories": round(tdee), "protein": 56,
504
+ "carbs": round(tdee*.5/4), "fat": round(tdee*.3/9),
505
+ "sodium": 2300, "fiber": 28, "sugar": 50}
506
+ pct = {k: min(100, round(totals.get(k, 0) / v * 100)) if v else 0
507
+ for k, v in (("cal","calories"),("prot","protein"),
508
+ ("carb","carbs"),("fat","fat"))}
509
+
510
+ cal_left = max(0, t["calories"] - totals["cal"])
511
+ prot_left = max(0, t["protein"] - totals["prot"])
512
+ suggestion = ""
513
+ if cal_left < 200: suggestion = "🎯 Almost at your calorie target — great tracking!"
514
+ elif prot_left > 20: suggestion = f"💪 {round(prot_left)}g protein left. Try: eggs, dal, paneer."
515
+ elif cal_left > 600: suggestion = f"🍽 {round(cal_left)} kcal remaining — a balanced meal fits well."
516
+
517
+ return {"date": target_date, "totals": totals, "targets": t, "pct": pct,
518
+ "suggestion": suggestion, "items": row["items"] or 0,
519
+ "log": [dict(r) for r in log_items]}
520
+
521
+
522
+ @app.post("/daily-log")
523
+ @limiter.limit("30/minute")
524
+ async def daily_log(
525
+ request : Request,
526
+ meal_name: str = Form(...),
527
+ calories : float = Form(0),
528
+ protein : float = Form(0),
529
+ carbs : float = Form(0),
530
+ fat : float = Form(0),
531
+ sodium : float = Form(0),
532
+ fiber : float = Form(0),
533
+ sugar : float = Form(0),
534
+ source : str = Form("manual"),
535
+ log_date : str = Form(None),
536
+ ):
537
+ user = _get_request_user(request)
538
+ device_key = _device_key(request)
539
+ target_date = log_date or datetime.date.today().isoformat()
540
+ user_id = user["id"] if user else None
541
+ _ensure_device(device_key)
542
+ with db_conn() as conn:
543
+ conn.execute(
544
+ "INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
545
+ (user_id, device_key, target_date, meal_name, calories, protein, carbs, fat, sodium, fiber, sugar, source)
546
+ )
547
+ return {"status": "logged", "date": target_date, "meal": meal_name}
548
+
549
+
550
+ @app.delete("/daily-log/{log_id}")
551
+ async def delete_log(request: Request, log_id: int):
552
+ device_key = _device_key(request)
553
+ with db_conn() as conn:
554
+ conn.execute("DELETE FROM daily_logs WHERE id=? AND device_key=?", (log_id, device_key))
555
+ return {"status": "deleted", "id": log_id}
556
+
557
+
558
+ @app.get("/allergen-profile")
559
+ async def get_allergen_profile(request: Request):
560
+ device_key = _device_key(request)
561
+ with db_conn() as conn:
562
+ row = conn.execute(
563
+ "SELECT allergens, conditions FROM allergen_profiles WHERE device_key=?", (device_key,)
564
+ ).fetchone()
565
+ if not row:
566
+ return {"allergens": [], "conditions": []}
567
+ return {"allergens": json.loads(row["allergens"] or "[]"),
568
+ "conditions": json.loads(row["conditions"] or "[]")}
569
+
570
+
571
+ @app.post("/allergen-profile")
572
+ async def set_allergen_profile(request: Request, allergens: str = Form("[]"),
573
+ conditions: str = Form("[]")):
574
+ device_key = _device_key(request)
575
+ _ensure_device(device_key)
576
+ with db_conn() as conn:
577
+ conn.execute(
578
+ "INSERT OR REPLACE INTO allergen_profiles(device_key,allergens,conditions,updated_at) VALUES(?,?,?,datetime('now'))",
579
+ (device_key, allergens, conditions)
580
+ )
581
+ return {"status": "saved"}
582
+
583
+
584
+ @app.post("/nps")
585
+ async def submit_nps(request: Request, score: int = Form(...), comment: str = Form("")):
586
+ if not 0 <= score <= 10:
587
+ return JSONResponse({"error": "Score must be 0-10"}, status_code=400)
588
+ user = _get_request_user(request)
589
+ device_key = _device_key(request)
590
+ with db_conn() as conn:
591
+ conn.execute(
592
+ "INSERT INTO nps_responses(device_key,user_id,score,comment) VALUES(?,?,?,?)",
593
+ (device_key, user["id"] if user else None, score, comment[:500])
594
+ )
595
+ return {"status": "thank_you"}
596
+
597
+
598
+ @app.get("/admin/analytics")
599
+ async def admin_analytics(admin_token: str = ""):
600
+ if admin_token != os.environ.get("ADMIN_TOKEN", "changeme"):
601
+ raise HTTPException(status_code=403, detail="Invalid token")
602
+ today = datetime.date.today().isoformat()
603
+ mkey = today[:7]
604
+ with db_conn() as conn:
605
+ dau = conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE DATE(scanned_at)=?", (today,)).fetchone()[0]
606
+ mau = conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE strftime('%Y-%m',scanned_at)=?", (mkey,)).fetchone()[0]
607
+ tot = conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0]
608
+ avg_s= conn.execute("SELECT AVG(score) FROM scans").fetchone()[0]
609
+ avg_n= conn.execute("SELECT AVG(score) FROM nps_responses").fetchone()[0]
610
+ npc = conn.execute("SELECT COUNT(*) FROM nps_responses").fetchone()[0]
611
+ users= conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
612
+ top = conn.execute("SELECT product_name,COUNT(*) c FROM scans GROUP BY product_name ORDER BY c DESC LIMIT 10").fetchall()
613
+ food_ct = conn.execute("SELECT COUNT(*) FROM food_products").fetchone()[0]
614
+ verified= conn.execute("SELECT COUNT(*) FROM food_products WHERE verified=1").fetchone()[0]
615
+ return {"dau": dau, "mau": mau, "total_scans": tot, "total_users": users,
616
+ "avg_score": round(avg_s or 0, 2), "avg_nps": round(avg_n or 0, 2),
617
+ "nps_count": npc, "dau_mau": round(dau/mau*100,1) if mau else 0,
618
+ "food_db": {"total": food_ct, "verified": verified},
619
+ "top_products": [{"name": r[0], "scans": r[1]} for r in top]}
620
+
621
+
622
+ @app.post("/admin/create-api-key")
623
+ async def create_api_key_endpoint(admin_token: str = Form(...),
624
+ client_name: str = Form(...),
625
+ plan: str = Form("business")):
626
+ if admin_token != os.environ.get("ADMIN_TOKEN", "changeme"):
627
+ raise HTTPException(status_code=403, detail="Invalid admin token")
628
+ key = "eak_" + secrets.token_urlsafe(32)
629
+ with db_conn() as conn:
630
+ conn.execute("INSERT INTO api_keys(api_key,client_name,plan) VALUES(?,?,?)",
631
+ (key, client_name, plan))
632
+ return {"api_key": key, "client": client_name, "plan": plan}
633
+
634
+
635
+ @app.post("/generate-share-card")
636
+ @limiter.limit("20/minute")
637
+ async def generate_share_card(
638
+ request: Request, product_name: str = Form(...),
639
+ score: int = Form(...), verdict: str = Form(...),
640
+ top_warning: str = Form(""), top_pro: str = Form(""),
641
+ ):
642
+ from PIL import Image, ImageDraw, ImageFont
643
+ W, H = 1080, 1080
644
+ img = Image.new("RGB", (W, H), (15, 17, 23))
645
+ draw = ImageDraw.Draw(img)
646
+ font = ImageFont.load_default()
647
+ s_rgb = (34,197,94) if score>=7 else (245,158,11) if score>=4 else (239,68,68)
648
+ def centered(text, y, fill):
649
+ try: tw = font.getbbox(text)[2] - font.getbbox(text)[0]
650
+ except: tw = len(text) * 6
651
+ draw.text(((W-tw)//2, y), text, fill=fill, font=font)
652
+ draw.ellipse([340,160,740,560], outline=s_rgb, width=18)
653
+ centered(str(score), 340, s_rgb); centered("/10", 430, (100,116,139))
654
+ pname = product_name[:38] + ("…" if len(product_name)>38 else "")
655
+ centered(pname, 600, (255,255,255)); centered(verdict[:50], 650, (148,163,184))
656
+ if top_pro:
657
+ draw.rectangle([60,700,1020,760], fill=(15,60,40))
658
+ centered(f"✓ {top_pro[:65]}", 718, (74,222,128))
659
+ if top_warning:
660
+ draw.rectangle([60,775,1020,840], fill=(124,29,29))
661
+ centered(f"⚠ {top_warning[:65]}", 795, (252,165,165))
662
+ centered("eatlytic.com • scan any food label, no barcode needed", 1000, (71,85,105))
663
+ draw.text((40, 1050), MEDICAL_DISCLAIMER[:90], fill=(50,50,60), font=font)
664
+ buf = BytesIO(); img.save(buf, format="PNG", optimize=True); buf.seek(0)
665
+ return Response(content=buf.getvalue(), media_type="image/png",
666
+ headers={"Content-Disposition": "attachment; filename=eatlytic-scan.png"})
667
+
668
+
669
+ @app.post("/export-pdf")
670
+ @limiter.limit("10/minute")
671
+ async def export_pdf(request: Request, analysis_json: str = Form(...)):
672
+ try:
673
+ data = json.loads(analysis_json)
674
+ except Exception:
675
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
676
+ try:
677
+ from reportlab.lib.pagesizes import A4
678
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
679
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
680
+ from reportlab.lib import colors as rl; from reportlab.lib.units import cm
681
+ except ImportError:
682
+ return JSONResponse({"error": "reportlab not installed"}, status_code=501)
683
+ buf = BytesIO()
684
+ doc = SimpleDocTemplate(buf, pagesize=A4, rightMargin=2*cm, leftMargin=2*cm,
685
+ topMargin=2*cm, bottomMargin=2*cm)
686
+ stys = getSampleStyleSheet(); story = []
687
+ story.append(Paragraph("Eatlytic Food Label Analysis", stys["Title"]))
688
+ story.append(Paragraph(f"Product: {data.get('product_name','Unknown')}", stys["Heading2"]))
689
+ story.append(Paragraph(MEDICAL_DISCLAIMER,
690
+ ParagraphStyle("d", parent=stys["Normal"], fontSize=8, textColor=rl.grey)))
691
+ story.append(Spacer(1,.4*cm))
692
+ score = data.get("score",0); sc = "22c55e" if score>=7 else "f59e0b" if score>=4 else "ef4444"
693
+ story.append(Paragraph(f"<font color='#{sc}'>Health Score: {score}/10 — {data.get('verdict','')}</font>", stys["Heading1"]))
694
+ if data.get("summary"):
695
+ story.append(Paragraph("Summary", stys["Heading2"]))
696
+ story.append(Paragraph(data["summary"], stys["Normal"]))
697
+ nutrients = data.get("nutrient_breakdown",[])
698
+ if nutrients:
699
+ story.append(Paragraph("Nutrient Breakdown", stys["Heading2"]))
700
+ td = [["Nutrient","Amount","Rating"]] + [
701
+ [str(n.get("name","")), f"{n.get('value','')} {n.get('unit','')}".strip(),
702
+ str(n.get("rating","")).upper()] for n in nutrients]
703
+ tbl = Table(td, colWidths=[6*cm,4*cm,4*cm])
704
+ tbl.setStyle(TableStyle([
705
+ ("BACKGROUND",(0,0),(-1,0),rl.HexColor("1D9E75")),("TEXTCOLOR",(0,0),(-1,0),rl.white),
706
+ ("FONTSIZE",(0,0),(-1,-1),10),("BACKGROUND",(0,1),(-1,-1),rl.HexColor("f8faf8")),
707
+ ("GRID",(0,0),(-1,-1),.4,rl.HexColor("d0d8d4")),
708
+ ("TOPPADDING",(0,0),(-1,-1),6),("BOTTOMPADDING",(0,0),(-1,-1),6),
709
+ ("LEFTPADDING",(0,0),(-1,-1),8),("RIGHTPADDING",(0,0),(-1,-1),8),
710
+ ])); story.append(tbl)
711
+ if data.get("pros"):
712
+ story.append(Paragraph("Benefits", stys["Heading2"]))
713
+ [story.append(Paragraph(f"✓ {p}", stys["Normal"])) for p in data["pros"]]
714
+ if data.get("cons"):
715
+ story.append(Paragraph("Concerns", stys["Heading2"]))
716
+ [story.append(Paragraph(f"✗ {c}", stys["Normal"])) for c in data["cons"]]
717
+ try:
718
+ doc.build(story)
719
+ except Exception as exc:
720
+ return JSONResponse({"error": f"PDF failed: {exc}"}, status_code=500)
721
+ buf.seek(0); safe = data.get("product_name","scan").replace(" ","-")[:40]
722
+ return Response(content=buf.getvalue(), media_type="application/pdf",
723
+ headers={"Content-Disposition": f"attachment; filename=eatlytic-{safe}.pdf"})
payments.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/routes/payments.py
3
+ Payment endpoints: create Razorpay order, verify payment, status.
4
+ Wraps app/services/payments.py logic.
5
+ """
6
+ import logging
7
+ from fastapi import APIRouter, Request, Form, HTTPException
8
+ from fastapi.responses import JSONResponse
9
+ from app.services.auth import get_user_from_token
10
+ from app.models.db import db_conn
11
+
12
+ logger = logging.getLogger(__name__)
13
+ router = APIRouter(prefix="/payments", tags=["payments"])
14
+
15
+
16
+ def _get_user_or_device(request: Request) -> tuple:
17
+ """Returns (user_id, device_key). Either may be None."""
18
+ auth = request.headers.get("Authorization", "")
19
+ token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
20
+ user = get_user_from_token(token) if token else None
21
+ user_id = user["id"] if user else None
22
+ import hashlib
23
+ ip = request.client.host if request.client else "unknown"
24
+ ua = request.headers.get("user-agent", "")
25
+ device_key = hashlib.md5(f"{ip}:{ua}".encode()).hexdigest()[:16]
26
+ return user_id, device_key
27
+
28
+
29
+ @router.post("/create-order")
30
+ async def create_order(request: Request):
31
+ """
32
+ Create a Razorpay order. Returns order_id + key_id for frontend modal.
33
+ Requires RAZORPAY_KEY_ID + RAZORPAY_KEY_SECRET env vars.
34
+ """
35
+ user_id, device_key = _get_user_or_device(request)
36
+ if not user_id:
37
+ raise HTTPException(status_code=401,
38
+ detail="Login required to create payment order. POST /auth/request-otp first.")
39
+ try:
40
+ from app.services.payments import create_order as _create
41
+ order = _create(user_id, device_key)
42
+ return JSONResponse(order)
43
+ except RuntimeError as exc:
44
+ raise HTTPException(status_code=503, detail=str(exc))
45
+ except Exception as exc:
46
+ logger.error("create_order failed: %s", exc)
47
+ raise HTTPException(status_code=500, detail="Payment service error")
48
+
49
+
50
+ @router.post("/verify")
51
+ async def verify_payment(
52
+ request : Request,
53
+ razorpay_order_id : str = Form(...),
54
+ razorpay_payment_id : str = Form(...),
55
+ razorpay_signature : str = Form(...),
56
+ ):
57
+ """
58
+ Called by frontend after Razorpay success callback.
59
+ Verifies HMAC signature and activates Pro.
60
+ """
61
+ try:
62
+ from app.services.payments import activate_pro_after_payment
63
+ result = activate_pro_after_payment(
64
+ razorpay_order_id, razorpay_payment_id, razorpay_signature
65
+ )
66
+ return JSONResponse(result)
67
+ except ValueError as exc:
68
+ raise HTTPException(status_code=400, detail=str(exc))
69
+ except RuntimeError as exc:
70
+ raise HTTPException(status_code=503, detail=str(exc))
71
+ except Exception as exc:
72
+ logger.error("verify_payment failed: %s", exc)
73
+ raise HTTPException(status_code=500, detail="Payment verification failed")
74
+
75
+
76
+ @router.get("/status/{order_id}")
77
+ async def payment_status(request: Request, order_id: str):
78
+ """Check status of a Razorpay order."""
79
+ try:
80
+ from app.services.payments import get_payment_status
81
+ status = get_payment_status(order_id)
82
+ if not status:
83
+ raise HTTPException(status_code=404, detail="Order not found")
84
+ return JSONResponse(status)
85
+ except HTTPException:
86
+ raise
87
+ except Exception as exc:
88
+ raise HTTPException(status_code=500, detail=str(exc))