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:
joungmin
2026-03-06 13:47:19 +09:00
commit 36bec10bd0
54 changed files with 9727 additions and 0 deletions

0
backend/api/__init__.py Normal file
View File

32
backend/api/deps.py Normal file
View 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
View 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"}

View File

View 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

View 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}

View 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)

View 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)

View 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

View 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}