Initial commit: Tasteby - YouTube restaurant map service
Backend (FastAPI + Oracle ADB), Frontend (Next.js), daemon worker. Features: channel/video/restaurant management, semantic search, Google OAuth, user reviews. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
32
backend/api/deps.py
Normal file
32
backend/api/deps.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""FastAPI dependencies for authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
from core.auth import verify_jwt
|
||||
|
||||
|
||||
def get_current_user(authorization: str = Header(None)) -> dict:
|
||||
"""Extract and verify Bearer token, return user payload.
|
||||
|
||||
Raises 401 if token is missing or invalid.
|
||||
"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(401, "Missing or invalid Authorization header")
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
try:
|
||||
return verify_jwt(token)
|
||||
except Exception:
|
||||
raise HTTPException(401, "Invalid or expired token")
|
||||
|
||||
|
||||
def get_optional_user(authorization: str = Header(None)) -> dict | None:
|
||||
"""Same as get_current_user but returns None if no token."""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return None
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
try:
|
||||
return verify_jwt(token)
|
||||
except Exception:
|
||||
return None
|
||||
32
backend/api/main.py
Normal file
32
backend/api/main.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.routes import restaurants, channels, videos, search, auth, reviews
|
||||
|
||||
app = FastAPI(
|
||||
title="Tasteby API",
|
||||
description="YouTube restaurant map service API",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:3001"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(restaurants.router, prefix="/api/restaurants", tags=["restaurants"])
|
||||
app.include_router(channels.router, prefix="/api/channels", tags=["channels"])
|
||||
app.include_router(videos.router, prefix="/api/videos", tags=["videos"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(reviews.router, prefix="/api", tags=["reviews"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
0
backend/api/routes/__init__.py
Normal file
0
backend/api/routes/__init__.py
Normal file
40
backend/api/routes/auth.py
Normal file
40
backend/api/routes/auth.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Auth API routes — Google SSO login and user info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.auth import verify_google_token, find_or_create_user, create_jwt
|
||||
from api.deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class GoogleLoginRequest(BaseModel):
|
||||
id_token: str
|
||||
|
||||
|
||||
@router.post("/google")
|
||||
def login_google(body: GoogleLoginRequest):
|
||||
"""Verify Google ID token and return JWT + user info."""
|
||||
try:
|
||||
google_info = verify_google_token(body.id_token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, f"Invalid Google token: {e}")
|
||||
|
||||
user = find_or_create_user(
|
||||
provider="google",
|
||||
provider_id=google_info["sub"],
|
||||
email=google_info.get("email"),
|
||||
nickname=google_info.get("name"),
|
||||
avatar_url=google_info.get("picture"),
|
||||
)
|
||||
access_token = create_jwt(user)
|
||||
return {"access_token": access_token, "user": user}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_me(current_user: dict = Depends(get_current_user)):
|
||||
"""Return current authenticated user info."""
|
||||
return current_user
|
||||
46
backend/api/routes/channels.py
Normal file
46
backend/api/routes/channels.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Channel API routes."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core import youtube
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ChannelCreate(BaseModel):
|
||||
channel_id: str
|
||||
channel_name: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_channels():
|
||||
return youtube.get_active_channels()
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_channel(body: ChannelCreate):
|
||||
try:
|
||||
row_id = youtube.add_channel(body.channel_id, body.channel_name)
|
||||
return {"id": row_id, "channel_id": body.channel_id}
|
||||
except Exception as e:
|
||||
if "UQ_CHANNELS_CID" in str(e).upper():
|
||||
raise HTTPException(409, "Channel already exists")
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/{channel_id}/scan")
|
||||
def scan_channel(channel_id: str):
|
||||
"""Trigger a scan for new videos from this channel."""
|
||||
channels = youtube.get_active_channels()
|
||||
ch = next((c for c in channels if c["channel_id"] == channel_id), None)
|
||||
if not ch:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
|
||||
videos = youtube.fetch_channel_videos(channel_id, max_results=50)
|
||||
new_count = 0
|
||||
for v in videos:
|
||||
row_id = youtube.save_video(ch["id"], v)
|
||||
if row_id:
|
||||
new_count += 1
|
||||
return {"total_fetched": len(videos), "new_videos": new_count}
|
||||
33
backend/api/routes/restaurants.py
Normal file
33
backend/api/routes/restaurants.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Restaurant API routes."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from core import restaurant
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_restaurants(
|
||||
limit: int = Query(100, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
cuisine: str | None = None,
|
||||
region: str | None = None,
|
||||
):
|
||||
return restaurant.get_all(limit=limit, offset=offset, cuisine=cuisine, region=region)
|
||||
|
||||
|
||||
@router.get("/{restaurant_id}")
|
||||
def get_restaurant(restaurant_id: str):
|
||||
r = restaurant.get_by_id(restaurant_id)
|
||||
if not r:
|
||||
raise HTTPException(404, "Restaurant not found")
|
||||
return r
|
||||
|
||||
|
||||
@router.get("/{restaurant_id}/videos")
|
||||
def get_restaurant_videos(restaurant_id: str):
|
||||
r = restaurant.get_by_id(restaurant_id)
|
||||
if not r:
|
||||
raise HTTPException(404, "Restaurant not found")
|
||||
return restaurant.get_video_links(restaurant_id)
|
||||
97
backend/api/routes/reviews.py
Normal file
97
backend/api/routes/reviews.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Review API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core import review
|
||||
from api.deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ReviewCreate(BaseModel):
|
||||
rating: float = Field(..., ge=0.5, le=5.0)
|
||||
review_text: Optional[str] = None
|
||||
visited_at: Optional[date] = None
|
||||
|
||||
|
||||
class ReviewUpdate(BaseModel):
|
||||
rating: Optional[float] = Field(None, ge=0.5, le=5.0)
|
||||
review_text: Optional[str] = None
|
||||
visited_at: Optional[date] = None
|
||||
|
||||
|
||||
# --- Restaurant reviews ---
|
||||
|
||||
@router.get("/restaurants/{restaurant_id}/reviews")
|
||||
def list_restaurant_reviews(
|
||||
restaurant_id: str,
|
||||
limit: int = Query(20, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List reviews for a restaurant (public)."""
|
||||
reviews = review.get_reviews_for_restaurant(restaurant_id, limit=limit, offset=offset)
|
||||
stats = review.get_restaurant_avg_rating(restaurant_id)
|
||||
return {"reviews": reviews, **stats}
|
||||
|
||||
|
||||
@router.post("/restaurants/{restaurant_id}/reviews", status_code=201)
|
||||
def create_restaurant_review(
|
||||
restaurant_id: str,
|
||||
body: ReviewCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a review for a restaurant (requires auth)."""
|
||||
created = review.create_review(
|
||||
user_id=current_user["sub"],
|
||||
restaurant_id=restaurant_id,
|
||||
rating=body.rating,
|
||||
review_text=body.review_text,
|
||||
visited_at=body.visited_at,
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
@router.put("/reviews/{review_id}")
|
||||
def update_review_route(
|
||||
review_id: str,
|
||||
body: ReviewUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a review (own review only)."""
|
||||
updated = review.update_review(
|
||||
review_id=review_id,
|
||||
user_id=current_user["sub"],
|
||||
rating=body.rating,
|
||||
review_text=body.review_text,
|
||||
visited_at=body.visited_at,
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(404, "Review not found or not yours")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/reviews/{review_id}", status_code=204)
|
||||
def delete_review_route(
|
||||
review_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a review (own review only)."""
|
||||
deleted = review.delete_review(review_id, current_user["sub"])
|
||||
if not deleted:
|
||||
raise HTTPException(404, "Review not found or not yours")
|
||||
|
||||
|
||||
@router.get("/users/me/reviews")
|
||||
def list_my_reviews(
|
||||
limit: int = Query(20, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List current user's reviews."""
|
||||
return review.get_user_reviews(current_user["sub"], limit=limit, offset=offset)
|
||||
66
backend/api/routes/search.py
Normal file
66
backend/api/routes/search.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Search API routes — keyword + semantic search."""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from core import restaurant, vector
|
||||
from core.db import conn
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def search_restaurants(
|
||||
q: str = Query(..., min_length=1),
|
||||
mode: str = Query("keyword", pattern="^(keyword|semantic|hybrid)$"),
|
||||
limit: int = Query(20, le=100),
|
||||
):
|
||||
"""Search restaurants by keyword, semantic similarity, or hybrid."""
|
||||
if mode == "semantic":
|
||||
return _semantic_search(q, limit)
|
||||
elif mode == "hybrid":
|
||||
kw = _keyword_search(q, limit)
|
||||
sem = _semantic_search(q, limit)
|
||||
# merge: keyword results first, then semantic results not already in keyword
|
||||
seen = {r["id"] for r in kw}
|
||||
merged = list(kw)
|
||||
for r in sem:
|
||||
if r["id"] not in seen:
|
||||
merged.append(r)
|
||||
seen.add(r["id"])
|
||||
return merged[:limit]
|
||||
else:
|
||||
return _keyword_search(q, limit)
|
||||
|
||||
|
||||
def _keyword_search(q: str, limit: int) -> list[dict]:
|
||||
sql = """
|
||||
SELECT id, name, address, region, latitude, longitude,
|
||||
cuisine_type, price_range
|
||||
FROM restaurants
|
||||
WHERE latitude IS NOT NULL
|
||||
AND (UPPER(name) LIKE UPPER(:q)
|
||||
OR UPPER(address) LIKE UPPER(:q)
|
||||
OR UPPER(region) LIKE UPPER(:q)
|
||||
OR UPPER(cuisine_type) LIKE UPPER(:q))
|
||||
FETCH FIRST :lim ROWS ONLY
|
||||
"""
|
||||
pattern = f"%{q}%"
|
||||
with conn() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(sql, {"q": pattern, "lim": limit})
|
||||
cols = [d[0].lower() for d in cur.description]
|
||||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _semantic_search(q: str, limit: int) -> list[dict]:
|
||||
similar = vector.search_similar(q, top_k=limit)
|
||||
if not similar:
|
||||
return []
|
||||
|
||||
rest_ids = list({s["restaurant_id"] for s in similar})
|
||||
results = []
|
||||
for rid in rest_ids[:limit]:
|
||||
r = restaurant.get_by_id(rid)
|
||||
if r and r.get("latitude"):
|
||||
results.append(r)
|
||||
return results
|
||||
52
backend/api/routes/videos.py
Normal file
52
backend/api/routes/videos.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Video API routes."""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from core.db import conn
|
||||
from core.pipeline import process_pending
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_videos(
|
||||
status: str | None = None,
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
conditions = []
|
||||
params: dict = {"lim": limit, "off": offset}
|
||||
if status:
|
||||
conditions.append("v.status = :st")
|
||||
params["st"] = status
|
||||
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
sql = f"""
|
||||
SELECT v.id, v.video_id, v.title, v.url, v.status,
|
||||
v.published_at, c.channel_name
|
||||
FROM videos v
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
{where}
|
||||
ORDER BY v.published_at DESC NULLS LAST
|
||||
OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY
|
||||
"""
|
||||
with conn() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(sql, params)
|
||||
cols = [d[0].lower() for d in cur.description]
|
||||
rows = cur.fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
d = dict(zip(cols, row))
|
||||
if d.get("published_at"):
|
||||
d["published_at"] = d["published_at"].isoformat()
|
||||
results.append(d)
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/process")
|
||||
def trigger_processing(limit: int = Query(5, le=20)):
|
||||
"""Manually trigger processing of pending videos."""
|
||||
count = process_pending(limit)
|
||||
return {"restaurants_extracted": count}
|
||||
Reference in New Issue
Block a user