saadrizvi09 commited on
Commit
86ac4e1
Β·
1 Parent(s): fa1ac5a
.dockerignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ venv/
8
+ env/
9
+ ENV/
10
+
11
+ # Environment files
12
+ .env
13
+ .env.local
14
+
15
+ # IDE
16
+ .vscode/
17
+ .idea/
18
+ *.swp
19
+ *.swo
20
+
21
+ # OS
22
+ .DS_Store
23
+ Thumbs.db
24
+
25
+ # Git
26
+ .git/
27
+ .gitignore
28
+
29
+ # Node (if any)
30
+ node_modules/
31
+ package-lock.json
32
+
33
+ # Misc
34
+ *.log
35
+ .pytest_cache/
36
+ .coverage
37
+ htmlcov/
.env.example ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Supabase ──────────────────────────────────────────────
2
+ # Get these from: https://supabase.com/dashboard β†’ Project Settings β†’ API
3
+ SUPABASE_URL="https://your-project.supabase.co"
4
+ SUPABASE_SERVICE_KEY="your_service_role_key_here"
5
+ SUPABASE_ANON_KEY="your_anon_key_here"
6
+
7
+ # ── Google Gemini AI ──────────────────────────────────────
8
+ # Get from: https://makersuite.google.com/app/apikey
9
+ GEMINI_API_KEY="your_gemini_api_key_here"
10
+
11
+ # ── Frontend URL (CORS) ──────────────────────────────────
12
+ FRONTEND_URL="http://localhost:3000"
.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables
2
+ .env
3
+ .env.local
4
+
5
+ # Python cache
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+ *.so
10
+ .Python
11
+
12
+ # Virtual environments
13
+ venv/
14
+ env/
15
+ ENV/
16
+ .venv
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+ *~
24
+
25
+ # OS
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # Logs
30
+ *.log
31
+
32
+ # Distribution / packaging
33
+ build/
34
+ dist/
35
+ *.egg-info/
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Spaces deployment
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y \
9
+ build-essential \
10
+ curl \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy requirements first for better caching
14
+ COPY requirements.txt .
15
+
16
+ # Install Python dependencies
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Copy application code
20
+ COPY . .
21
+
22
+ # Expose port (Hugging Face Spaces uses 7860 by default)
23
+ EXPOSE 7860
24
+
25
+ # Set environment variables
26
+ ENV PYTHONUNBUFFERED=1
27
+ ENV PORT=7860
28
+
29
+ # Run the application
30
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,62 @@
1
  ---
2
- title: NoMoosh
3
- emoji: πŸ“Š
4
- colorFrom: gray
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
- short_description: app
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Nomoosh API
3
+ emoji: 🍽️
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
+ license: mit
9
  ---
10
 
11
+ # Nomoosh Backend API
12
+
13
+ FastAPI backend for the Nomoosh restaurant onboarding and ordering platform.
14
+
15
+ ## Features
16
+
17
+ - πŸ” Authentication with Supabase
18
+ - πŸ“‹ Multi-step restaurant onboarding
19
+ - πŸ€– AI-powered menu digitization using Google Gemini
20
+ - πŸ“ Geocoding and location services
21
+ - ☁️ File storage with Supabase Storage
22
+ - πŸ—„οΈ PostgreSQL database via Supabase
23
+
24
+ ## Environment Variables
25
+
26
+ This Space requires the following environment variables to be set in Settings β†’ Variables and secrets:
27
+
28
+ ```bash
29
+ SUPABASE_URL=your_supabase_project_url
30
+ SUPABASE_SERVICE_KEY=your_supabase_service_role_key
31
+ SUPABASE_ANON_KEY=your_supabase_anon_key
32
+ GEMINI_API_KEY=your_google_gemini_api_key
33
+ FRONTEND_URL=your_frontend_url
34
+ ```
35
+
36
+ ## API Documentation
37
+
38
+ Once deployed, visit:
39
+ - `/docs` - Interactive Swagger UI
40
+ - `/redoc` - ReDoc documentation
41
+ - `/health` - Health check endpoint
42
+
43
+ ## Local Development
44
+
45
+ ```bash
46
+ # Install dependencies
47
+ pip install -r requirements.txt
48
+
49
+ # Create .env file with your credentials
50
+ cp .env.example .env
51
+
52
+ # Run the server
53
+ uvicorn main:app --reload
54
+ ```
55
+
56
+ ## Deployment
57
+
58
+ This backend is designed to be deployed on Hugging Face Spaces using Docker SDK.
59
+
60
+ ## License
61
+
62
+ MIT
auth_utils.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers to extract / verify the Supabase JWT from incoming requests."""
2
+
3
+ from __future__ import annotations
4
+ from fastapi import Header, HTTPException
5
+ from supabase_client import get_supabase
6
+
7
+
8
+ async def get_current_user_id(authorization: str = Header(None)) -> str:
9
+ """Require a valid Supabase access-token. Returns the Supabase user-id (UUID)."""
10
+ if not authorization or not authorization.startswith("Bearer "):
11
+ raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
12
+ token = authorization.split(" ", 1)[1]
13
+ try:
14
+ sb = get_supabase()
15
+ user_resp = sb.auth.get_user(token)
16
+ if not user_resp or not user_resp.user:
17
+ raise HTTPException(status_code=401, detail="Invalid token")
18
+ return user_resp.user.id
19
+ except HTTPException:
20
+ raise
21
+ except Exception as e:
22
+ raise HTTPException(status_code=401, detail=f"Token verification failed: {e}")
23
+
24
+
25
+ async def get_optional_user_id(authorization: str = Header(None)) -> str | None:
26
+ """Same as above but returns *None* instead of 401 when token is absent/invalid."""
27
+ if not authorization or not authorization.startswith("Bearer "):
28
+ return None
29
+ try:
30
+ return await get_current_user_id(authorization)
31
+ except Exception:
32
+ return None
config.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralised configuration β€” reads from .env via python-dotenv."""
2
+
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ SUPABASE_URL: str = os.getenv("SUPABASE_URL", "")
9
+ SUPABASE_SERVICE_KEY: str = os.getenv("SUPABASE_SERVICE_KEY", "")
10
+ SUPABASE_ANON_KEY: str = os.getenv("SUPABASE_ANON_KEY", "")
11
+ GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "")
12
+ FRONTEND_URL: str = os.getenv("FRONTEND_URL", "http://localhost:3000")
13
+
14
+ # Storage bucket name in Supabase
15
+ STORAGE_BUCKET: str = "restaurant-media"
main.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nomoosh Backend β€” FastAPI entry point.
3
+
4
+ Run with:
5
+ uvicorn main:app --reload --port 8000
6
+ """
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+
11
+ from config import FRONTEND_URL
12
+ from routers import auth, onboarding, menu, geocode
13
+
14
+ app = FastAPI(
15
+ title="Nomoosh API",
16
+ description="Restaurant onboarding & ordering platform",
17
+ version="1.0.0",
18
+ )
19
+
20
+ # ── CORS ──────────────────────────────────────────────────
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=[
24
+ FRONTEND_URL,
25
+ "http://localhost:3000",
26
+ "http://127.0.0.1:3000",
27
+ ],
28
+ allow_credentials=True,
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+ # ── Routers ───────────────────────────────────────────────
34
+ app.include_router(auth.router)
35
+ app.include_router(onboarding.router)
36
+ app.include_router(menu.router)
37
+ app.include_router(geocode.router)
38
+
39
+
40
+ # ── Health & root ─────────────────────────────────────────
41
+ @app.get("/")
42
+ def root():
43
+ return {"status": "ok", "service": "Nomoosh API"}
44
+
45
+
46
+ @app.get("/health")
47
+ def health():
48
+ return {"status": "healthy"}
package-lock.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "backend",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.0
3
+ python-multipart==0.0.12
4
+ supabase==2.11.0
5
+ python-dotenv==1.0.1
6
+ httpx==0.28.0
7
+ google-generativeai==0.8.4
8
+ Pillow==11.1.0
9
+ PyMuPDF==1.25.3
10
+ pydantic>=2.0
routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # routers package
routers/auth.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Auth router β€” create_user endpoint.
2
+
3
+ The actual email-OTP / Google-OAuth flows happen client-side via @supabase/supabase-js.
4
+ This endpoint is called *after* the user has already authenticated with Supabase to
5
+ create (or retrieve) an application-level profile row in the `accounts` table.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from fastapi import APIRouter, HTTPException
10
+ from pydantic import BaseModel
11
+ from supabase_client import get_supabase
12
+
13
+ router = APIRouter(tags=["auth"])
14
+
15
+
16
+ # ── Request / Response models ──────────────────────────────
17
+ class CreateUserRequest(BaseModel):
18
+ name: str
19
+ email: str | None = None
20
+ phone: str | None = None
21
+ supabase_uid: str | None = None
22
+
23
+
24
+ class CreateUserResponse(BaseModel):
25
+ user_id: str
26
+ message: str = "ok"
27
+
28
+
29
+ # ── Endpoint ──────────────────────────────────────────────
30
+ @router.post("/create_user", response_model=CreateUserResponse)
31
+ async def create_user(req: CreateUserRequest):
32
+ """Create or return an existing account row."""
33
+ sb = get_supabase()
34
+
35
+ # 1. Look-up by Supabase UID first (most reliable after OAuth)
36
+ if req.supabase_uid:
37
+ existing = (
38
+ sb.table("accounts")
39
+ .select("id")
40
+ .eq("supabase_uid", req.supabase_uid)
41
+ .execute()
42
+ )
43
+ if existing.data:
44
+ return CreateUserResponse(user_id=str(existing.data[0]["id"]))
45
+
46
+ # 2. Fallback: look-up by email
47
+ if req.email:
48
+ existing = (
49
+ sb.table("accounts")
50
+ .select("id")
51
+ .eq("email", req.email)
52
+ .execute()
53
+ )
54
+ if existing.data:
55
+ # Patch supabase_uid if it was missing
56
+ if req.supabase_uid:
57
+ sb.table("accounts").update({"supabase_uid": req.supabase_uid}).eq(
58
+ "id", existing.data[0]["id"]
59
+ ).execute()
60
+ return CreateUserResponse(user_id=str(existing.data[0]["id"]))
61
+
62
+ # 3. Fallback: look-up by phone
63
+ if req.phone:
64
+ existing = (
65
+ sb.table("accounts")
66
+ .select("id")
67
+ .eq("mob_number", req.phone)
68
+ .execute()
69
+ )
70
+ if existing.data:
71
+ if req.supabase_uid:
72
+ sb.table("accounts").update({"supabase_uid": req.supabase_uid}).eq(
73
+ "id", existing.data[0]["id"]
74
+ ).execute()
75
+ return CreateUserResponse(user_id=str(existing.data[0]["id"]))
76
+
77
+ # 4. No match β†’ create
78
+ insert_data: dict = {"name": req.name[:25]}
79
+ if req.email:
80
+ insert_data["email"] = req.email
81
+ if req.phone:
82
+ insert_data["mob_number"] = req.phone
83
+ if req.supabase_uid:
84
+ insert_data["supabase_uid"] = req.supabase_uid
85
+
86
+ result = sb.table("accounts").insert(insert_data).execute()
87
+ if not result.data:
88
+ raise HTTPException(status_code=500, detail="Failed to create account")
89
+
90
+ return CreateUserResponse(user_id=str(result.data[0]["id"]))
routers/geocode.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Geocode router β€” free reverse-geocoding via OpenStreetMap Nominatim."""
2
+
3
+ from __future__ import annotations
4
+ from fastapi import APIRouter, HTTPException
5
+ from pydantic import BaseModel
6
+ import httpx
7
+
8
+ router = APIRouter(tags=["geocode"])
9
+
10
+ NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"
11
+ USER_AGENT = "NomooshApp/1.0 (support@nomoosh.com)" # Nominatim requires a User-Agent
12
+
13
+
14
+ class ReverseGeocodeRequest(BaseModel):
15
+ lat: float
16
+ lng: float
17
+ language: str = "en"
18
+
19
+
20
+ @router.post("/geocode/reverse")
21
+ async def reverse_geocode(data: ReverseGeocodeRequest):
22
+ """
23
+ Reverse-geocode latitude/longitude β†’ address components.
24
+ Uses the free Nominatim (OpenStreetMap) API.
25
+ """
26
+ params = {
27
+ "lat": data.lat,
28
+ "lon": data.lng,
29
+ "format": "jsonv2",
30
+ "addressdetails": 1,
31
+ "accept-language": data.language,
32
+ }
33
+
34
+ try:
35
+ async with httpx.AsyncClient(timeout=10) as client:
36
+ resp = await client.get(
37
+ NOMINATIM_URL,
38
+ params=params,
39
+ headers={"User-Agent": USER_AGENT},
40
+ )
41
+ resp.raise_for_status()
42
+ body = resp.json()
43
+ except httpx.HTTPStatusError as e:
44
+ raise HTTPException(status_code=502, detail=f"Nominatim error: {e.response.status_code}")
45
+ except Exception as e:
46
+ raise HTTPException(status_code=502, detail=f"Geocoding failed: {e}")
47
+
48
+ addr = body.get("address", {})
49
+
50
+ # Map Nominatim fields β†’ our schema
51
+ street = " ".join(filter(None, [
52
+ addr.get("house_number"),
53
+ addr.get("road"),
54
+ addr.get("neighbourhood"),
55
+ ]))
56
+ locality = addr.get("suburb") or addr.get("village") or addr.get("town") or ""
57
+ city = (
58
+ addr.get("city")
59
+ or addr.get("town")
60
+ or addr.get("municipality")
61
+ or addr.get("county")
62
+ or ""
63
+ )
64
+ pincode = addr.get("postcode") or ""
65
+
66
+ return {
67
+ "latitude": data.lat,
68
+ "longitude": data.lng,
69
+ "street": street,
70
+ "locality": locality,
71
+ "city": city,
72
+ "pincode": pincode,
73
+ "display_name": body.get("display_name", ""),
74
+ "raw": addr,
75
+ }
routers/menu.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Menu router β€” digitise menu images via Gemini, upload dish photos, save menu items.
2
+
3
+ Endpoints:
4
+ POST /digitize-menu β†’ parse menu from images/PDFs (Gemini AI)
5
+ POST /upload-image β†’ upload a single dish photo to Supabase Storage
6
+ POST /upload-restaurant-media→ upload restaurant photos/videos
7
+ POST /register-restaurant_pg2β†’ save finalised menu items to temp_menu
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
14
+ from pydantic import BaseModel
15
+ from supabase_client import get_supabase
16
+ from services.gemini_service import parse_menu_images
17
+ from services.storage_service import upload_to_storage
18
+
19
+ router = APIRouter(tags=["menu"])
20
+
21
+
22
+ # ═══════════════════════════════════════════════════════════
23
+ # MODELS
24
+ # ═══════════════════════════════════════════════════════════
25
+
26
+ class VariantPayload(BaseModel):
27
+ variant_name: str = "Regular"
28
+ price: int | float = 0
29
+
30
+
31
+ class ItemPayload(BaseModel):
32
+ name: str
33
+ variants: list[VariantPayload] = []
34
+ description: str = ""
35
+ image_link: str = ""
36
+
37
+
38
+ class CategoryPayload(BaseModel):
39
+ category: str
40
+ items: list[ItemPayload] = []
41
+
42
+
43
+ class MenuPayload(BaseModel):
44
+ restaurant_name: str = ""
45
+ categories: list[CategoryPayload] = []
46
+
47
+
48
+ class RegisterPg2Request(BaseModel):
49
+ user_id: int
50
+ menu: MenuPayload
51
+
52
+
53
+ # ═══════════════════════════════════════════════════════════
54
+ # POST /digitize-menu
55
+ # ═══════════════════════════════════════════════════════════
56
+
57
+ @router.post("/digitize-menu")
58
+ async def digitize_menu(files: list[UploadFile] = File(...)):
59
+ """Accept menu image(s) / PDF, run Gemini vision to extract structured menu JSON."""
60
+ if not files:
61
+ raise HTTPException(status_code=400, detail="No files provided")
62
+
63
+ file_data: list[tuple[bytes, str]] = []
64
+ for f in files:
65
+ content = await f.read()
66
+ ct = f.content_type or "application/octet-stream"
67
+ file_data.append((content, ct))
68
+
69
+ try:
70
+ result = await parse_menu_images(file_data)
71
+ return {"menu": result}
72
+ except Exception as e:
73
+ raise HTTPException(status_code=500, detail=f"Menu parsing failed: {e}")
74
+
75
+
76
+ # ═══════════════════════════════════════════════════════════
77
+ # POST /upload-image (single dish photo)
78
+ # ═══════════════════════════════════════════════════════════
79
+
80
+ @router.post("/upload-image")
81
+ async def upload_image(
82
+ file: UploadFile = File(...),
83
+ itemId: str = Form(""),
84
+ ):
85
+ """Upload a dish photo β†’ Supabase Storage, return public URL."""
86
+ content = await file.read()
87
+ ct = file.content_type or "image/jpeg"
88
+ filename = file.filename or "photo.jpg"
89
+ try:
90
+ url = await upload_to_storage(content, filename, ct, folder="dishes")
91
+ return {"url": url}
92
+ except Exception as e:
93
+ raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
94
+
95
+
96
+ # ═══════════════════════════════════════════════════════════
97
+ # POST /upload-restaurant-media (bulk restaurant photos/videos)
98
+ # ═══════════════════════════════════════════════════════════
99
+
100
+ @router.post("/upload-restaurant-media")
101
+ async def upload_restaurant_media(request: Request):
102
+ """
103
+ Accepts multipart form with keys like:
104
+ menu_files[], exterior_photos[], interior_photos[], etc.
105
+ Uploads each to Supabase Storage and records in temp_media.
106
+ """
107
+ form = await request.form()
108
+ user_id_raw = form.get("user_id")
109
+
110
+ sb = get_supabase()
111
+ uploaded: list[dict] = []
112
+
113
+ for key, value in form.multi_items():
114
+ if key == "user_id":
115
+ continue
116
+
117
+ # Only process file-like values
118
+ if not hasattr(value, "read"):
119
+ continue
120
+
121
+ content = await value.read() # type: ignore[union-attr]
122
+ ct = getattr(value, "content_type", "application/octet-stream") or "application/octet-stream"
123
+ fname = getattr(value, "filename", "file") or "file"
124
+
125
+ url = await upload_to_storage(content, fname, ct, folder="restaurant")
126
+ uploaded.append({"key": key, "url": url})
127
+
128
+ # Persist in temp_media if we have a user_id
129
+ if user_id_raw:
130
+ is_ext = "exterior" in key
131
+ is_int = "interior" in key
132
+ is_kit = "kitchen" in key
133
+ is_menu = "menu" in key
134
+ is_video = "video" in key
135
+
136
+ sb.table("temp_media").insert({
137
+ "user_id": int(user_id_raw),
138
+ "file_link": url,
139
+ "exterior": is_ext,
140
+ "interior": is_int,
141
+ "kitchen": is_kit,
142
+ "menu": is_menu,
143
+ "video": is_video,
144
+ }).execute()
145
+
146
+ return {"message": "Media uploaded", "count": len(uploaded), "files": uploaded}
147
+
148
+
149
+ # ═══════════════════════════════════════════════════════════
150
+ # POST /register-restaurant_pg2 (save parsed/edited menu items)
151
+ # ═══════════════════════════════════════════════════════════
152
+
153
+ @router.post("/register-restaurant_pg2")
154
+ async def register_restaurant_pg2(data: RegisterPg2Request):
155
+ """Persist the (possibly edited) menu items into temp_menu."""
156
+ sb = get_supabase()
157
+ user_id = data.user_id
158
+
159
+ # Verify temp row exists
160
+ temp_check = sb.table("temp").select("user_id").eq("user_id", user_id).execute()
161
+ if not temp_check.data:
162
+ raise HTTPException(
163
+ status_code=404,
164
+ detail="Onboarding session not found β€” complete step 1 first.",
165
+ )
166
+
167
+ # Clear previous menu drafts
168
+ sb.table("temp_menu").delete().eq("user_id", user_id).execute()
169
+
170
+ # Insert each item (one row per variant)
171
+ for cat in data.menu.categories:
172
+ for item in cat.items:
173
+ if not item.variants:
174
+ # Single-price dish
175
+ sb.table("temp_menu").insert({
176
+ "user_id": user_id,
177
+ "dish_name": item.name[:100],
178
+ "price": 0,
179
+ "category": cat.category,
180
+ "variant_name": "Regular",
181
+ "image_link": item.image_link or None,
182
+ "description": item.description or None,
183
+ }).execute()
184
+ else:
185
+ for v in item.variants:
186
+ sb.table("temp_menu").insert({
187
+ "user_id": user_id,
188
+ "dish_name": item.name[:100],
189
+ "price": int(v.price) if v.price else 0,
190
+ "category": cat.category,
191
+ "variant_name": v.variant_name or "Regular",
192
+ "image_link": item.image_link or None,
193
+ "description": item.description or None,
194
+ }).execute()
195
+
196
+ return {"message": "Menu saved", "user_id": user_id}
routers/onboarding.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Onboarding router β€” multi-step restaurant registration.
2
+
3
+ Endpoints:
4
+ GET /check-onboarding-status β†’ check which steps are completed
5
+ POST /register-restaurant_pg1 β†’ save restaurant details & location (step 1)
6
+ POST /save-cuisines-and-times β†’ save cuisines + operating hours (step 3)
7
+ POST /save-documents β†’ save bank/PAN + finalise (step 4)
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from fastapi import APIRouter, HTTPException, Query
12
+ from pydantic import BaseModel
13
+ from supabase_client import get_supabase
14
+
15
+ router = APIRouter(tags=["onboarding"])
16
+
17
+
18
+ # ═══════════════════════════════════════════════════════════
19
+ # CHECK ONBOARDING STATUS
20
+ # ═══════════════════════════════════════════════════════════
21
+
22
+ @router.get("/check-onboarding-status")
23
+ async def check_onboarding_status(user_id: int = Query(...)):
24
+ """Check which onboarding steps have data in the database."""
25
+ sb = get_supabase()
26
+
27
+ try:
28
+ # Check step 1: Restaurant details (temp table)
29
+ temp_res = sb.table("temp").select("user_id").eq("user_id", user_id).execute()
30
+ details_completed = len(temp_res.data or []) > 0
31
+
32
+ # Check step 2: Menu items (temp_menu table)
33
+ menu_res = sb.table("temp_menu").select("id").eq("user_id", user_id).limit(1).execute()
34
+ menu_completed = len(menu_res.data or []) > 0
35
+
36
+ # Check step 3: Cuisines and timing (temp_rest_cuisines or temp_rest_timing)
37
+ cuisine_res = sb.table("temp_rest_cuisines").select("user_id").eq("user_id", user_id).limit(1).execute()
38
+ timing_res = sb.table("temp_rest_timing").select("user_id").eq("user_id", user_id).limit(1).execute()
39
+ cuisine_completed = len(cuisine_res.data or []) > 0 or len(timing_res.data or []) > 0
40
+
41
+ # Step 4 (documents) is only complete when everything is finalized (moved to permanent tables)
42
+ # So we don't check for it in temp tables
43
+
44
+ return {
45
+ "details": details_completed,
46
+ "menu": menu_completed,
47
+ "cuisine": cuisine_completed,
48
+ "documents": False, # always false in temp tables
49
+ }
50
+ except Exception as e:
51
+ # If tables don't exist or query fails, return all false
52
+ return {
53
+ "details": False,
54
+ "menu": False,
55
+ "cuisine": False,
56
+ "documents": False,
57
+ }
58
+
59
+
60
+ @router.get("/get-onboarding-data")
61
+ async def get_onboarding_data(user_id: int = Query(...)):
62
+ """Fetch saved onboarding data from temp tables to restore form state."""
63
+ sb = get_supabase()
64
+
65
+ try:
66
+ # Fetch restaurant details from temp table
67
+ temp_res = sb.table("temp").select("*").eq("user_id", user_id).execute()
68
+ temp_data = temp_res.data[0] if temp_res.data else None
69
+
70
+ return {
71
+ "temp": temp_data,
72
+ }
73
+ except Exception as e:
74
+ raise HTTPException(status_code=500, detail=f"Failed to fetch onboarding data: {e}")
75
+
76
+
77
+ # ═══════════════════════════════════════════════════════════
78
+ # MODELS
79
+ # ═══════════════════════════════════════════════════════════
80
+
81
+ class RestaurantPg1(BaseModel):
82
+ usr_id: str | None = None
83
+ rest_name: str
84
+ rest_phone: str = ""
85
+ rest_intro: str = ""
86
+ ownr_name: str
87
+ ownr_email: str
88
+ ownr_mobile: str
89
+ strret: str = ""
90
+ localty: str = ""
91
+ cty: str = ""
92
+ pincde: str = ""
93
+ landmrk: str = ""
94
+ latitude: str | None = None
95
+ longitude: str | None = None
96
+
97
+
98
+ class Slot(BaseModel):
99
+ open: str
100
+ close: str
101
+
102
+
103
+ class CuisineTimesRequest(BaseModel):
104
+ restaurant_id: str | None = None
105
+ cuisines: list[str] = []
106
+ open_days: dict[str, bool] = {}
107
+ timings: dict[str, list[Slot]] = {}
108
+
109
+
110
+ class DocumentsPayload(BaseModel):
111
+ pan: str = ""
112
+ account_holder: str = ""
113
+ account_number: str = ""
114
+ ifsc: str = ""
115
+
116
+
117
+ class SaveDocumentsRequest(BaseModel):
118
+ restaurantId: str | None = None
119
+ documents: DocumentsPayload
120
+
121
+
122
+ # ═══════════════════════════════════════════════════════════
123
+ # STEP 1 β€” Restaurant details & location
124
+ # ═══════════════════════════════════════════════════════════
125
+
126
+ @router.post("/register-restaurant_pg1")
127
+ async def register_restaurant_pg1(data: RestaurantPg1):
128
+ sb = get_supabase()
129
+
130
+ user_id = data.usr_id
131
+ if not user_id:
132
+ raise HTTPException(status_code=400, detail="usr_id is required")
133
+
134
+ user_id_int = int(user_id)
135
+
136
+ # Ensure temp row exists (upsert)
137
+ existing = sb.table("temp").select("user_id").eq("user_id", user_id_int).execute()
138
+
139
+ row = {
140
+ "user_id": user_id_int,
141
+ "restaurant_name": data.rest_name[:100],
142
+ "owner_name": data.ownr_name[:25],
143
+ "owner_email": data.ownr_email,
144
+ "owner_mobile": data.ownr_mobile,
145
+ "rest_mob_number": data.rest_phone,
146
+ "description": data.rest_intro,
147
+ "street": data.strret,
148
+ "locality": data.localty,
149
+ "city": data.cty,
150
+ "pincode": data.pincde,
151
+ "landmark": data.landmrk,
152
+ "latitude": data.latitude or "0",
153
+ "longitude": data.longitude or "0",
154
+ }
155
+
156
+ if existing.data:
157
+ sb.table("temp").update(row).eq("user_id", user_id_int).execute()
158
+ else:
159
+ sb.table("temp").insert(row).execute()
160
+
161
+ return {"message": "Restaurant details saved", "restaurant_id": str(user_id_int)}
162
+
163
+
164
+ # ═══════════════════════════════════════════════════════════
165
+ # STEP 3 β€” Cuisines & timings
166
+ # ═══════════════════════════════════════════════════════════
167
+
168
+ @router.post("/save-cuisines-and-times")
169
+ async def save_cuisines_and_times(data: CuisineTimesRequest):
170
+ sb = get_supabase()
171
+
172
+ user_id_str = data.restaurant_id
173
+ if not user_id_str:
174
+ raise HTTPException(status_code=400, detail="restaurant_id is required")
175
+
176
+ user_id = int(user_id_str)
177
+
178
+ # Verify temp row exists
179
+ temp_check = sb.table("temp").select("user_id").eq("user_id", user_id).execute()
180
+ if not temp_check.data:
181
+ raise HTTPException(status_code=404, detail="Onboarding session not found. Complete step 1 first.")
182
+
183
+ # ── Cuisines ──────────────────────────────────────────
184
+ # Clear old selections
185
+ sb.table("temp_rest_cuisines").delete().eq("user_id", user_id).execute()
186
+
187
+ for cuisine_name in data.cuisines:
188
+ cuis = sb.table("cuisines").select("id").eq("cuisine", cuisine_name).execute()
189
+ if cuis.data:
190
+ sb.table("temp_rest_cuisines").insert({
191
+ "user_id": user_id,
192
+ "cuisine_id": cuis.data[0]["id"],
193
+ }).execute()
194
+
195
+ # ── Timings ───────────────────────────────────────────
196
+ sb.table("temp_rest_timing").delete().eq("user_id", user_id).execute()
197
+
198
+ for day, slots in data.timings.items():
199
+ for slot in slots:
200
+ sb.table("temp_rest_timing").insert({
201
+ "user_id": user_id,
202
+ "day": day,
203
+ "open_time": slot.open,
204
+ "close_time": slot.close,
205
+ }).execute()
206
+
207
+ return {"message": "Cuisines and timings saved"}
208
+
209
+
210
+ # ═══════════════════════════════════════════════════════════
211
+ # STEP 4 β€” Documents + FINALISE registration
212
+ # ═══════════════════════════════════════════════════════════
213
+
214
+ @router.post("/save-documents")
215
+ async def save_documents(data: SaveDocumentsRequest):
216
+ sb = get_supabase()
217
+
218
+ user_id_str = data.restaurantId
219
+ if not user_id_str:
220
+ raise HTTPException(status_code=400, detail="restaurantId is required")
221
+
222
+ user_id = int(user_id_str)
223
+
224
+ # ── 1. Update temp with bank/PAN info ─────────────────
225
+ sb.table("temp").update({
226
+ "pan": data.documents.pan,
227
+ "account_holder": data.documents.account_holder,
228
+ "account_no": data.documents.account_number,
229
+ "ifsc": data.documents.ifsc,
230
+ }).eq("user_id", user_id).execute()
231
+
232
+ # ── 2. Finalise: move everything from temp β†’ permanent tables
233
+ temp_res = sb.table("temp").select("*").eq("user_id", user_id).single().execute()
234
+ if not temp_res.data:
235
+ raise HTTPException(status_code=404, detail="Onboarding data not found")
236
+ t = temp_res.data
237
+
238
+ # 2a. rest_location
239
+ loc = sb.table("rest_location").insert({
240
+ "street": t.get("street") or "",
241
+ "locality": t.get("locality") or "",
242
+ "city": t.get("city") or "",
243
+ "pincode": t.get("pincode") or "",
244
+ "landmark": t.get("landmark"),
245
+ "latitude": t.get("latitude") or "0",
246
+ "longitude": t.get("longitude") or "0",
247
+ }).execute()
248
+ location_id = loc.data[0]["id"]
249
+
250
+ # 2b. bank_details
251
+ bank = sb.table("bank_details").insert({
252
+ "pan": t.get("pan"),
253
+ "account_holder": t.get("account_holder"),
254
+ "account_no": t.get("account_no"),
255
+ "ifsc": t.get("ifsc"),
256
+ }).execute()
257
+ bank_id = bank.data[0]["id"]
258
+
259
+ # 2c. restaurants
260
+ rest = sb.table("restaurants").insert({
261
+ "name": t.get("restaurant_name") or "Unnamed",
262
+ "accounts_id": user_id,
263
+ "location_id": location_id,
264
+ "bank_id": bank_id,
265
+ "mob_number": t.get("rest_mob_number") or "",
266
+ "description": t.get("description"),
267
+ }).execute()
268
+ restaurant_id = rest.data[0]["id"]
269
+
270
+ # 2d. menu (from temp_menu)
271
+ temp_menu = sb.table("temp_menu").select("*").eq("user_id", user_id).execute()
272
+ for item in (temp_menu.data or []):
273
+ sb.table("menu").insert({
274
+ "restaurant_id": restaurant_id,
275
+ "dish_name": item["dish_name"],
276
+ "price": item.get("price", 0),
277
+ "category_veg": item.get("category_veg"),
278
+ "description": item.get("description"),
279
+ "image_link": item.get("image_link"),
280
+ "cuisine": item.get("cuisine"),
281
+ "category": item.get("category"),
282
+ "variant_name": item.get("variant_name", "Regular"),
283
+ }).execute()
284
+
285
+ # 2e. rest_cuisines (from temp_rest_cuisines)
286
+ temp_cuis = sb.table("temp_rest_cuisines").select("*").eq("user_id", user_id).execute()
287
+ for c in (temp_cuis.data or []):
288
+ sb.table("rest_cuisines").insert({
289
+ "restaurant_id": restaurant_id,
290
+ "cuisine_id": c["cuisine_id"],
291
+ }).execute()
292
+
293
+ # 2f. rest_timing (from temp_rest_timing)
294
+ temp_timing = sb.table("temp_rest_timing").select("*").eq("user_id", user_id).execute()
295
+ for tt in (temp_timing.data or []):
296
+ sb.table("rest_timing").insert({
297
+ "restaurant_id": restaurant_id,
298
+ "day": tt["day"],
299
+ "open_time": tt["open_time"],
300
+ "close_time": tt["close_time"],
301
+ }).execute()
302
+
303
+ # 2g. rest_media (from temp_media)
304
+ temp_media = sb.table("temp_media").select("*").eq("user_id", user_id).execute()
305
+ for m in (temp_media.data or []):
306
+ sb.table("rest_media").insert({
307
+ "restaurant_id": restaurant_id,
308
+ "image_link": m["file_link"],
309
+ "exterior": m.get("exterior", False),
310
+ "interior": m.get("interior", False),
311
+ "kitchen": m.get("kitchen", False),
312
+ "video": m.get("video", False),
313
+ }).execute()
314
+
315
+ # ── 3. Cleanup temp tables ────────────────────────────
316
+ sb.table("temp_media").delete().eq("user_id", user_id).execute()
317
+ sb.table("temp_menu").delete().eq("user_id", user_id).execute()
318
+ sb.table("temp_rest_cuisines").delete().eq("user_id", user_id).execute()
319
+ sb.table("temp_rest_timing").delete().eq("user_id", user_id).execute()
320
+ sb.table("temp").delete().eq("user_id", user_id).execute()
321
+
322
+ return {
323
+ "message": "Registration complete!",
324
+ "restaurant_id": restaurant_id,
325
+ }
schema.sql ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- ==========================================================
2
+ -- Nomoosh β€” Supabase-compatible PostgreSQL schema
3
+ -- Run this in the Supabase SQL Editor (Dashboard β†’ SQL β†’ New Query)
4
+ -- ==========================================================
5
+
6
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
7
+
8
+ -- ==========================================================
9
+ -- 1. ONBOARDING / MASTER DATA
10
+ -- ==========================================================
11
+
12
+ -- Accounts (Restaurant Owner / Admin)
13
+ CREATE TABLE IF NOT EXISTS accounts (
14
+ id SERIAL PRIMARY KEY,
15
+ supabase_uid TEXT UNIQUE, -- links to auth.users.id
16
+ name VARCHAR(25) NOT NULL,
17
+ email VARCHAR(255) UNIQUE,
18
+ mob_number VARCHAR(16) UNIQUE,
19
+ profile_pic TEXT,
20
+ created_at TIMESTAMPTZ DEFAULT now(),
21
+ updated_at TIMESTAMPTZ DEFAULT now()
22
+ );
23
+
24
+ -- Restaurant Location
25
+ CREATE TABLE IF NOT EXISTS rest_location (
26
+ id SERIAL PRIMARY KEY,
27
+ street TEXT NOT NULL,
28
+ locality TEXT NOT NULL,
29
+ city TEXT NOT NULL,
30
+ pincode TEXT NOT NULL,
31
+ landmark TEXT,
32
+ latitude TEXT NOT NULL DEFAULT '0',
33
+ longitude TEXT NOT NULL DEFAULT '0'
34
+ );
35
+
36
+ -- Bank Details
37
+ CREATE TABLE IF NOT EXISTS bank_details (
38
+ id SERIAL PRIMARY KEY,
39
+ pan TEXT,
40
+ bank_name TEXT,
41
+ account_holder TEXT,
42
+ account_no TEXT,
43
+ ifsc TEXT,
44
+ upi TEXT
45
+ );
46
+
47
+ -- Restaurants (Master)
48
+ CREATE TABLE IF NOT EXISTS restaurants (
49
+ id SERIAL PRIMARY KEY,
50
+ name VARCHAR(100) NOT NULL,
51
+ accounts_id INT NOT NULL UNIQUE,
52
+ location_id INT NOT NULL UNIQUE,
53
+ bank_id INT UNIQUE,
54
+ rating REAL,
55
+ rating_by_no_of_people INT,
56
+ mob_number VARCHAR(16) NOT NULL DEFAULT '',
57
+ description TEXT,
58
+
59
+ FOREIGN KEY (accounts_id) REFERENCES accounts(id),
60
+ FOREIGN KEY (location_id) REFERENCES rest_location(id),
61
+ FOREIGN KEY (bank_id) REFERENCES bank_details(id)
62
+ );
63
+
64
+ -- Cuisines (pre-seeded below)
65
+ CREATE TABLE IF NOT EXISTS cuisines (
66
+ id SERIAL PRIMARY KEY,
67
+ cuisine VARCHAR(50) NOT NULL UNIQUE
68
+ );
69
+
70
+ -- Restaurant ↔ Cuisines
71
+ CREATE TABLE IF NOT EXISTS rest_cuisines (
72
+ restaurant_id INT NOT NULL,
73
+ cuisine_id INT NOT NULL,
74
+ PRIMARY KEY (restaurant_id, cuisine_id),
75
+ FOREIGN KEY (restaurant_id) REFERENCES restaurants(id) ON DELETE CASCADE,
76
+ FOREIGN KEY (cuisine_id) REFERENCES cuisines(id)
77
+ );
78
+
79
+ -- Menu
80
+ CREATE TABLE IF NOT EXISTS menu (
81
+ id SERIAL PRIMARY KEY,
82
+ restaurant_id INT NOT NULL,
83
+ dish_name VARCHAR(100) NOT NULL,
84
+ type_maincourse TEXT,
85
+ quantity TEXT,
86
+ price INT NOT NULL DEFAULT 0,
87
+ category_veg BOOLEAN,
88
+ availability BOOLEAN DEFAULT TRUE NOT NULL,
89
+ preperation_time INTERVAL,
90
+ image_link TEXT,
91
+ description TEXT,
92
+ customizability BOOLEAN DEFAULT FALSE NOT NULL,
93
+ order_freq TEXT,
94
+ serving_for_no_of_people INT,
95
+ rating REAL,
96
+ rating_by_no_of_people INT,
97
+ cuisine VARCHAR(50),
98
+ category TEXT,
99
+ variant_name TEXT DEFAULT 'Regular',
100
+
101
+ FOREIGN KEY (restaurant_id) REFERENCES restaurants(id) ON DELETE CASCADE
102
+ );
103
+
104
+ -- Restaurant Timing (open/close per day per shift)
105
+ CREATE TABLE IF NOT EXISTS rest_timing (
106
+ id SERIAL PRIMARY KEY,
107
+ restaurant_id INT NOT NULL,
108
+ day VARCHAR(9) NOT NULL,
109
+ open_time TIME NOT NULL,
110
+ close_time TIME NOT NULL,
111
+
112
+ FOREIGN KEY (restaurant_id) REFERENCES restaurants(id) ON DELETE CASCADE
113
+ );
114
+
115
+ -- Restaurant Media
116
+ CREATE TABLE IF NOT EXISTS rest_media (
117
+ id SERIAL PRIMARY KEY,
118
+ restaurant_id INT NOT NULL,
119
+ image_link TEXT NOT NULL,
120
+ exterior BOOLEAN DEFAULT FALSE NOT NULL,
121
+ interior BOOLEAN DEFAULT FALSE NOT NULL,
122
+ lavatory BOOLEAN DEFAULT FALSE NOT NULL,
123
+ kitchen BOOLEAN DEFAULT FALSE NOT NULL,
124
+ video BOOLEAN DEFAULT FALSE NOT NULL,
125
+
126
+ FOREIGN KEY (restaurant_id) REFERENCES restaurants(id) ON DELETE CASCADE
127
+ );
128
+
129
+ -- Customer Feedback
130
+ CREATE TABLE IF NOT EXISTS customer_feedback (
131
+ id SERIAL PRIMARY KEY,
132
+ customer_id INT NOT NULL,
133
+ restaurant_id INT NOT NULL,
134
+ food INT CHECK (food BETWEEN 1 AND 5),
135
+ staff_behavior INT CHECK (staff_behavior BETWEEN 1 AND 5),
136
+ hygiene INT CHECK (hygiene BETWEEN 1 AND 5),
137
+ optional TEXT,
138
+
139
+ FOREIGN KEY (restaurant_id) REFERENCES restaurants(id) ON DELETE CASCADE
140
+ );
141
+
142
+ -- ==========================================================
143
+ -- 2. TEMP / DRAFT TABLES (ONBOARDING FLOW)
144
+ -- ==========================================================
145
+
146
+ CREATE TABLE IF NOT EXISTS temp (
147
+ user_id INT PRIMARY KEY,
148
+ restaurant_name VARCHAR(100),
149
+ owner_name VARCHAR(25),
150
+ owner_email VARCHAR(255),
151
+ owner_mobile VARCHAR(16),
152
+ street TEXT,
153
+ locality TEXT,
154
+ city TEXT,
155
+ pincode TEXT,
156
+ landmark TEXT,
157
+ latitude TEXT,
158
+ longitude TEXT,
159
+ bank_name TEXT,
160
+ account_no TEXT,
161
+ ifsc TEXT,
162
+ upi TEXT,
163
+ rest_mob_number VARCHAR(16),
164
+ description TEXT,
165
+ pan TEXT,
166
+ account_holder TEXT,
167
+
168
+ FOREIGN KEY (user_id) REFERENCES accounts(id) ON DELETE CASCADE
169
+ );
170
+
171
+ CREATE TABLE IF NOT EXISTS temp_media (
172
+ id SERIAL PRIMARY KEY,
173
+ user_id INT NOT NULL,
174
+ file_link TEXT NOT NULL,
175
+ exterior BOOLEAN DEFAULT FALSE NOT NULL,
176
+ interior BOOLEAN DEFAULT FALSE NOT NULL,
177
+ kitchen BOOLEAN DEFAULT FALSE NOT NULL,
178
+ menu BOOLEAN DEFAULT FALSE NOT NULL,
179
+ video BOOLEAN DEFAULT FALSE NOT NULL,
180
+
181
+ FOREIGN KEY (user_id) REFERENCES temp(user_id) ON DELETE CASCADE
182
+ );
183
+
184
+ CREATE TABLE IF NOT EXISTS temp_menu (
185
+ id SERIAL PRIMARY KEY,
186
+ user_id INT NOT NULL,
187
+ dish_name VARCHAR(100) NOT NULL,
188
+ type_maincourse TEXT,
189
+ quantity TEXT,
190
+ price INT NOT NULL DEFAULT 0,
191
+ category_veg BOOLEAN,
192
+ availability BOOLEAN DEFAULT TRUE NOT NULL,
193
+ preperation_time INTERVAL,
194
+ image_link TEXT,
195
+ description TEXT,
196
+ customizability BOOLEAN DEFAULT FALSE NOT NULL,
197
+ serving_for_no_of_people INT,
198
+ cuisine VARCHAR(50),
199
+ category TEXT,
200
+ variant_name TEXT DEFAULT 'Regular',
201
+
202
+ FOREIGN KEY (user_id) REFERENCES temp(user_id) ON DELETE CASCADE
203
+ );
204
+
205
+ CREATE TABLE IF NOT EXISTS temp_rest_timing (
206
+ id SERIAL PRIMARY KEY,
207
+ user_id INT NOT NULL,
208
+ day VARCHAR(9) NOT NULL,
209
+ open_time TIME NOT NULL,
210
+ close_time TIME NOT NULL,
211
+
212
+ FOREIGN KEY (user_id) REFERENCES temp(user_id) ON DELETE CASCADE
213
+ );
214
+
215
+ CREATE TABLE IF NOT EXISTS temp_rest_cuisines (
216
+ user_id INT NOT NULL,
217
+ cuisine_id INT NOT NULL,
218
+ PRIMARY KEY (user_id, cuisine_id),
219
+ FOREIGN KEY (user_id) REFERENCES temp(user_id) ON DELETE CASCADE,
220
+ FOREIGN KEY (cuisine_id) REFERENCES cuisines(id)
221
+ );
222
+
223
+ -- ==========================================================
224
+ -- 3. CORE APPLICATION (TRANSACTIONAL)
225
+ -- ==========================================================
226
+
227
+ CREATE TABLE IF NOT EXISTS restaurant_tables (
228
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
229
+ restaurant_id INT NOT NULL,
230
+ number VARCHAR(50) NOT NULL,
231
+ FOREIGN KEY (restaurant_id) REFERENCES restaurants(id) ON DELETE CASCADE
232
+ );
233
+
234
+ DO $$ BEGIN
235
+ CREATE TYPE session_status AS ENUM ('active','payment_pending','completed','expired');
236
+ EXCEPTION
237
+ WHEN duplicate_object THEN null;
238
+ END $$;
239
+
240
+ CREATE TABLE IF NOT EXISTS sessions (
241
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
242
+ table_id UUID NOT NULL,
243
+ restaurant_id INT NOT NULL,
244
+ status session_status DEFAULT 'active',
245
+ created_at TIMESTAMPTZ DEFAULT now(),
246
+ expires_at TIMESTAMPTZ,
247
+ payment_lock BOOLEAN DEFAULT FALSE,
248
+ FOREIGN KEY (table_id) REFERENCES restaurant_tables(id),
249
+ FOREIGN KEY (restaurant_id) REFERENCES restaurants(id)
250
+ );
251
+
252
+ DO $$ BEGIN
253
+ CREATE TYPE participant_role AS ENUM ('guest','host');
254
+ EXCEPTION
255
+ WHEN duplicate_object THEN null;
256
+ END $$;
257
+
258
+ CREATE TABLE IF NOT EXISTS participants (
259
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
260
+ session_id UUID NOT NULL,
261
+ device_fingerprint VARCHAR(255),
262
+ joined_at TIMESTAMPTZ DEFAULT now(),
263
+ last_active_at TIMESTAMPTZ DEFAULT now(),
264
+ role participant_role DEFAULT 'guest',
265
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
266
+ );
267
+
268
+ CREATE TABLE IF NOT EXISTS carts (
269
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
270
+ session_id UUID NOT NULL UNIQUE,
271
+ version INT DEFAULT 1,
272
+ updated_at TIMESTAMPTZ DEFAULT now(),
273
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
274
+ );
275
+
276
+ CREATE TABLE IF NOT EXISTS cart_items (
277
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
278
+ cart_id UUID NOT NULL,
279
+ menu_item_id INT NOT NULL,
280
+ quantity INT DEFAULT 1,
281
+ added_by UUID,
282
+ notes TEXT,
283
+ created_at TIMESTAMPTZ DEFAULT now(),
284
+ FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE,
285
+ FOREIGN KEY (menu_item_id) REFERENCES menu(id),
286
+ FOREIGN KEY (added_by) REFERENCES participants(id)
287
+ );
288
+
289
+ DO $$ BEGIN
290
+ CREATE TYPE order_status AS ENUM ('pending','paid','failed');
291
+ EXCEPTION
292
+ WHEN duplicate_object THEN null;
293
+ END $$;
294
+
295
+ CREATE TABLE IF NOT EXISTS orders (
296
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
297
+ session_id UUID NOT NULL,
298
+ total_amount DECIMAL(10,2) NOT NULL,
299
+ status order_status DEFAULT 'pending',
300
+ transaction_id VARCHAR(255),
301
+ created_at TIMESTAMPTZ DEFAULT now(),
302
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
303
+ );
304
+
305
+ CREATE TABLE IF NOT EXISTS order_items (
306
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
307
+ order_id UUID NOT NULL,
308
+ menu_item_id INT NOT NULL,
309
+ quantity INT NOT NULL,
310
+ price_at_time DECIMAL(10,2) NOT NULL,
311
+ FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
312
+ FOREIGN KEY (menu_item_id) REFERENCES menu(id)
313
+ );
314
+
315
+ -- ==========================================================
316
+ -- 4. PAYMENTS
317
+ -- ==========================================================
318
+
319
+ DO $$ BEGIN
320
+ CREATE TYPE payment_status AS ENUM ('initiated','success','failed','refunded');
321
+ EXCEPTION
322
+ WHEN duplicate_object THEN null;
323
+ END $$;
324
+
325
+ DO $$ BEGIN
326
+ CREATE TYPE payment_method AS ENUM ('upi','card','wallet','net_banking','cash');
327
+ EXCEPTION
328
+ WHEN duplicate_object THEN null;
329
+ END $$;
330
+
331
+ CREATE TABLE IF NOT EXISTS payments (
332
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
333
+ order_id UUID NOT NULL,
334
+ participant_id UUID NOT NULL,
335
+ amount DECIMAL(10,2) NOT NULL,
336
+ method payment_method NOT NULL,
337
+ status payment_status DEFAULT 'initiated',
338
+ gateway_transaction_id VARCHAR(255),
339
+ gateway_response JSONB,
340
+ created_at TIMESTAMPTZ DEFAULT now(),
341
+ updated_at TIMESTAMPTZ DEFAULT now(),
342
+ FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
343
+ FOREIGN KEY (participant_id) REFERENCES participants(id)
344
+ );
345
+
346
+ -- ==========================================================
347
+ -- 5. SEED: Cuisines
348
+ -- ==========================================================
349
+ INSERT INTO cuisines (cuisine) VALUES
350
+ ('North Indian'),('South Indian'),('Chinese'),('Fast Food'),('Biryani'),
351
+ ('Pizza'),('Bakery'),('Street Food'),('Burger'),('Mughlai'),
352
+ ('Momos'),('Sandwich'),('Fresh Veggie'),('Kebab'),('Ice Cream'),
353
+ ('Cafe'),('Healthy Food'),('Italian'),('Continental'),('Lebanese'),
354
+ ('Salad'),('Shawarma'),('Gujarati'),('Andhra'),('Waffle'),
355
+ ('Coffee'),('Rajasthani'),('Wraps'),('Mexican'),('Bengali'),
356
+ ('Sushi'),('Lucknowi'),('Goan'),('Assamese'),('American'),
357
+ ('Mandi'),('Chettinad'),('Mishti'),('Bar Food'),('Malwani'),
358
+ ('Odia'),('Japanese'),('Finger Food'),('Korean'),('North Eastern'),
359
+ ('Thai'),('Steak'),('Frozen Yogurt'),('Panini'),('Parsi'),
360
+ ('Sichuan'),('Iranian'),('Grilled Chicken'),('French'),('Raw Meats'),
361
+ ('Drinks Only'),('Vietnamese'),('Liquor'),('Greek'),('Himachali'),
362
+ ('Bohri'),('Garhwali'),('Cantonese'),('Malaysian'),('Belgian'),
363
+ ('British'),('African'),('Spanish'),('Manipur'),('Egyptian')
364
+ ON CONFLICT (cuisine) DO NOTHING;
365
+
366
+ -- ==========================================================
367
+ -- 6. SUPABASE STORAGE (run these manually in Dashboard)
368
+ -- ==========================================================
369
+ -- 1. Go to Supabase Dashboard β†’ Storage β†’ Create bucket
370
+ -- Name: restaurant-media | Public: ON
371
+ --
372
+ -- 2. Add a Storage Policy (allow uploads via service key):
373
+ -- INSERT policy: Allowed for service_role
374
+ -- SELECT policy: Allowed for everyone (public read)
375
+ --
376
+ -- These are NOT SQL commands β€” do them through the Dashboard UI.
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # services package
services/gemini_service.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gemini vision service β€” extracts structured menu data from images / PDFs.
2
+
3
+ Uses the free-tier of Google Gemini (gemini-1.5-flash) which supports image input.
4
+ For PDFs β†’ pages are rasterised with PyMuPDF first, then sent as images.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import re
12
+ from typing import Any
13
+
14
+ import google.generativeai as genai
15
+ from PIL import Image
16
+
17
+ from config import GEMINI_API_KEY
18
+
19
+ # ── Configure Gemini ──────────────────────────────────────
20
+ if GEMINI_API_KEY:
21
+ genai.configure(api_key=GEMINI_API_KEY)
22
+
23
+ _model = None
24
+
25
+
26
+ def _get_model():
27
+ global _model
28
+ if _model is None:
29
+ _model = genai.GenerativeModel("gemini-2.5-flash")
30
+ return _model
31
+
32
+
33
+ # ── Prompt ────────────────────────────────────────────────
34
+ MENU_PROMPT = """You are an expert restaurant-menu digitiser.
35
+
36
+ Analyse the menu image(s) below and extract **every** dish you can see.
37
+
38
+ Return ONLY valid JSON (no markdown fences, no explanation) in this exact format:
39
+
40
+ {
41
+ "restaurant_name": "Name if visible, else empty string",
42
+ "categories": [
43
+ {
44
+ "category": "Category Name (e.g. Starters, Main Course, Beverages)",
45
+ "items": [
46
+ {
47
+ "name": "Dish Name",
48
+ "description": "Brief description if visible, else empty string",
49
+ "variants": [
50
+ { "variant_name": "Half", "price": 150 },
51
+ { "variant_name": "Full", "price": 250 }
52
+ ]
53
+ }
54
+ ]
55
+ }
56
+ ]
57
+ }
58
+
59
+ Rules:
60
+ - If a dish has only one price, use variant_name "Regular".
61
+ - Price must be a number (no currency symbols).
62
+ - Include ALL dishes visible in the menu.
63
+ - Group dishes logically by category β€” use "General" if unclear.
64
+ - If the same dish has sizes (Half / Full / Regular / Large), list them as variants.
65
+ - Keep names concise, preserve original language if non-English.
66
+ """
67
+
68
+
69
+ # ── PDF β†’ images ─────────────────────────────────────────
70
+ def _pdf_to_pil_images(pdf_bytes: bytes) -> list[Image.Image]:
71
+ """Convert every page of a PDF to a PIL Image via PyMuPDF (fitz)."""
72
+ try:
73
+ import fitz # PyMuPDF
74
+ except ImportError:
75
+ raise RuntimeError(
76
+ "PyMuPDF is required for PDF menu parsing. "
77
+ "Install it with: pip install PyMuPDF"
78
+ )
79
+
80
+ images: list[Image.Image] = []
81
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
82
+ for page in doc:
83
+ pix = page.get_pixmap(dpi=150)
84
+ img = Image.open(io.BytesIO(pix.tobytes("png")))
85
+ images.append(img)
86
+ doc.close()
87
+ return images
88
+
89
+
90
+ # ── Parse Gemini response ─────────────────────────────────
91
+ def _extract_json(text: str) -> dict[str, Any]:
92
+ """Best-effort JSON extraction β€” handles markdown code-fences."""
93
+ # Try stripping ```json ... ``` first
94
+ m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text)
95
+ raw = m.group(1) if m else text.strip()
96
+ return json.loads(raw)
97
+
98
+
99
+ # ── Main entry point ──────────────────────────────────────
100
+ async def parse_menu_images(
101
+ file_data: list[tuple[bytes, str]],
102
+ ) -> dict[str, Any]:
103
+ """
104
+ Accepts a list of (raw_bytes, content_type) tuples.
105
+ Returns the structured menu dict.
106
+ """
107
+ if not GEMINI_API_KEY:
108
+ raise RuntimeError("GEMINI_API_KEY is not configured β€” see .env.example")
109
+
110
+ model = _get_model()
111
+ parts: list[Any] = [MENU_PROMPT]
112
+
113
+ for raw, ct in file_data:
114
+ if ct == "application/pdf":
115
+ for img in _pdf_to_pil_images(raw):
116
+ parts.append(img)
117
+ elif ct.startswith("image/"):
118
+ img = Image.open(io.BytesIO(raw))
119
+ parts.append(img)
120
+ else:
121
+ # Try to open as image anyway (some browsers send generic MIME)
122
+ try:
123
+ img = Image.open(io.BytesIO(raw))
124
+ parts.append(img)
125
+ except Exception:
126
+ continue # skip unsupported files
127
+
128
+ if len(parts) <= 1:
129
+ raise ValueError("No valid images found in the uploaded files")
130
+
131
+ response = model.generate_content(parts)
132
+ text = response.text
133
+ if not text:
134
+ raise ValueError("Gemini returned an empty response")
135
+
136
+ menu = _extract_json(text)
137
+ return menu
services/storage_service.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Storage service β€” upload files to Supabase Storage.
2
+
3
+ Bucket expected: ``restaurant-media`` (created via Supabase Dashboard, public read).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import uuid
9
+ from supabase_client import get_supabase
10
+ from config import STORAGE_BUCKET
11
+
12
+
13
+ async def upload_to_storage(
14
+ file_bytes: bytes,
15
+ filename: str,
16
+ content_type: str,
17
+ folder: str = "uploads",
18
+ ) -> str:
19
+ """
20
+ Upload a file to Supabase Storage and return the public URL.
21
+
22
+ :param file_bytes: raw bytes of the file
23
+ :param filename: original filename (used for extension)
24
+ :param content_type: MIME type
25
+ :param folder: sub-folder inside the bucket
26
+ :return: public URL
27
+ """
28
+ sb = get_supabase()
29
+
30
+ # Deterministic unique path: <folder>/<uuid>.<ext>
31
+ ext = filename.rsplit(".", 1)[-1] if "." in filename else "bin"
32
+ path = f"{folder}/{uuid.uuid4()}.{ext}"
33
+
34
+ sb.storage.from_(STORAGE_BUCKET).upload(
35
+ path=path,
36
+ file=file_bytes,
37
+ file_options={"content-type": content_type},
38
+ )
39
+
40
+ public_url: str = sb.storage.from_(STORAGE_BUCKET).get_public_url(path)
41
+ return public_url
supabase_client.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lazy-initialised Supabase client (uses service-role key β†’ bypasses RLS)."""
2
+
3
+ from __future__ import annotations
4
+ from supabase import create_client, Client
5
+ from config import SUPABASE_URL, SUPABASE_SERVICE_KEY
6
+
7
+ _client: Client | None = None
8
+
9
+
10
+ def get_supabase() -> Client:
11
+ global _client
12
+ if _client is None:
13
+ if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
14
+ raise RuntimeError(
15
+ "SUPABASE_URL and SUPABASE_SERVICE_KEY must be set in .env "
16
+ "β†’ see .env.example"
17
+ )
18
+ _client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
19
+ return _client