diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9fd89f3..0f00f43 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,6 +37,9 @@ jobs: bash scripts/fetch-secrets.sh bash scripts/fetch-ml-secrets.sh + echo "=== Ensuring shared bridge network exists ===" + docker network create soko-ml-bridge 2>/dev/null || true + echo "=== Restarting core platform ===" docker compose pull --ignore-pull-failures 2>/dev/null || true docker compose up -d --build --remove-orphans @@ -49,8 +52,8 @@ jobs: docker image prune -f echo "=== Verifying health ===" - sleep 15 - for port in 8001 8002 8003 8004 8005 8007 8008 8009; do + sleep 90 + for port in 8001 8002 8003 8004 8005 8007 8008 8009 8080; do status=$(curl -sf --max-time 5 http://localhost:${port}/health | jq -r '.status' 2>/dev/null || echo "unreachable") echo "Service :${port} -> ${status}" done diff --git a/docker-compose.yml b/docker-compose.yml index 0e00bfc..1a195bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,7 +87,6 @@ services: DATABASE_URL: postgresql://auth_user:${AUTH_DB_PASS:-auth_pass}@auth_db:5432/auth_db USER_SERVICE_URL: http://user_service:8002 REDIS_URL: redis://redis:6379/2 - BOT_SECRET: soko-bot-secret-change-in-prod depends_on: auth_db: condition: service_healthy diff --git a/infrastructure/cloudfront.tf b/infrastructure/cloudfront.tf index 55d609a..1acd00a 100644 --- a/infrastructure/cloudfront.tf +++ b/infrastructure/cloudfront.tf @@ -98,19 +98,23 @@ resource "aws_cloudfront_distribution" "frontend" { max_ttl = 0 } - # complete-profile: forward query_string so the ?access_token= param (set by - # the backend after Google OAuth for returning users) reaches the SPA. + # complete-profile: must reach EC2 for POST (form submit). + # GET returns 404 from the auth service, which CloudFront's custom_error_response + # catches and serves index.html so the SPA still loads. ordered_cache_behavior { path_pattern = "/auth/complete-profile" - target_origin_id = "s3-frontend" + target_origin_id = "ec2-api" viewer_protocol_policy = "redirect-to-https" - allowed_methods = ["GET", "HEAD"] + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"] cached_methods = ["GET", "HEAD"] - compress = true + compress = false + forwarded_values { query_string = true - cookies { forward = "none" } + headers = ["Authorization", "Content-Type", "Accept", "Origin", "X-User-Id", "X-User-Role"] + cookies { forward = "all" } } + min_ttl = 0 default_ttl = 0 max_ttl = 0 diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 1ac2b7a..72c2ffc 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -145,9 +145,10 @@ http { limit_req zone=api_limit burst=20 nodelay; set $auth_svc "auth_service:8001"; proxy_pass http://$auth_svc$auth_upstream_path; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; } location /oauth/ { diff --git a/scripts/.seed_manifest.json b/scripts/.seed_manifest.json index 453f607..b767a20 100644 --- a/scripts/.seed_manifest.json +++ b/scripts/.seed_manifest.json @@ -119,42 +119,42 @@ } ], "listing_ids": [ - "a1881336-12c1-4253-b7e7-6f601b9e0a08", - "e75af1bf-f6d4-4f4b-a387-1f54e8676ed1", - "0e776356-9934-4eba-b911-33370174032d", - "44a36d19-11f9-44c1-b6eb-1475b38fb667", - "b1443480-2920-4d64-a3d8-a9a61dc89596", - "75dbfa7a-6650-4938-9809-9c92ee4afd68", - "7ebc20bb-2f7d-4aca-a530-7a684c6e1bf6", - "b19c1a23-ce80-4666-92b0-2ef8859bfde8", - "b604e46b-2060-4b00-82d6-187f18d79173", - "65ab4997-c4a3-4e34-bccd-532820a9440f", - "f8e7ef91-222d-437e-a40d-ecd91ba8d97c", - "32945110-6c5c-4d8c-9e4a-e8bbc7ba7444", - "7ac3b02a-116c-43e8-a25d-f1968d169a8c", - "7fc508ca-4e1d-4132-9dcf-db149996cd0a", - "ebf680f4-c76b-4c1a-a977-bb1ca7664f8f", - "99e36ee6-9515-475d-8ca8-1731eedff989", - "cbbc017c-38c0-4f01-a77e-92d5aaf7fef5", - "463c30f7-f1e1-426e-bb84-2cecfa429fb8", - "cc260dec-2cfc-487a-a697-d148c4042ba2", - "8dbe2177-a4ed-4e15-9693-bc03cea8a596", - "f8b79aac-2386-474c-9b65-cddd71a2c4fa", - "b870c505-f284-43a6-9f53-46010ab1cb32", - "5fbbf2ac-4fda-4b2c-8eb5-4ef1942a3bc5", - "503950c9-94a4-47c4-91c2-e11dc85c8339", - "68db090c-b472-4de2-8fa5-f849d746bec6" + "416fa083-87e5-4102-ada2-605513dbffed", + "c0830a23-0798-43c4-9ce8-241ac3745a18", + "cece82fb-10fa-4ab0-85c9-1d391a32abdf", + "54b34337-c1dc-463d-9a90-2b520b53ed29", + "5427d0c2-3fc3-4db4-acd1-06addf4e5509", + "9c4ac485-522d-4da6-b81b-79d14950011a", + "33d25a41-075e-439a-b137-b3f6e0402538", + "191a56d7-6cdc-4ba9-ae1e-4315837031de", + "a2077d7e-bcd2-45d0-b738-58eba1c9f370", + "448a7a7a-c82a-4318-b107-7d4d17115f40", + "898644da-4fc3-4aff-a0b6-aa93eec9533b", + "f5a82f6e-40bd-4d95-97f7-234050aa3d83", + "404963f5-b92f-485f-b4bf-a37ec033e7e8", + "535e15d2-eeff-4cd2-8af6-a3ac5ad8debe", + "fa3c3dce-72d4-486a-8ebc-8bb731cf0a7c", + "b340091a-ae19-43a1-bafe-5392c7d20ecc", + "989e681c-9640-4b1c-95ce-a707c500d6b1", + "3b16d11a-7664-4161-a1ad-7ec4a8caf816", + "7a34155b-37a1-47e9-ac95-e071c6401538", + "e5c97328-3619-4a7e-b769-beda5bc1af60", + "0422aeff-9eda-42f1-ade9-314f7537ef36", + "89453ace-b691-4694-9da1-7f833081e8df", + "efc79519-57e7-4c63-8a48-17bd527e3a05", + "ca5b7fe0-cee0-4358-b858-903c34037cbb", + "71f698d4-6ddb-4c2a-b8cd-0e57fe55ca71" ], "order_ids": [ - "73a5edda-740c-40b3-8a0a-183cc3b43b6e", - "6a6b74ea-2735-41fc-9abc-a7cd546b1009", - "bff8c355-fb1a-4a20-a3f6-7a97c01d6d34", - "c9967bd1-2185-4a45-b240-fb2dc2420089", - "37b40b06-9586-4d94-aa86-954fc8cea3f6", - "e218e66c-ae80-4c6b-8303-60fcd289e5fc", - "263e19c2-22c5-4cf7-9eb6-42ceb734caa4", - "5b0b77bb-d634-45ac-9c07-99df5ee06428", - "adffbd85-5e0a-4380-b4fb-fac5891d83ba", - "38ef29b6-a314-48a7-b078-77054b5fe7c6" + "c82dd2ee-1824-4c1e-9fff-d61de1cfb0eb", + "58f88d80-5966-429f-b3ea-7943507a0725", + "bef494c8-1b92-419f-a148-cedc6e1ba323", + "08bf66bf-2860-41d0-a747-0cb760124002", + "5084cec4-6b82-45b1-93c1-ddde5d2b0509", + "f83dc042-2976-4822-80a8-b29a25aba348", + "41f3ef35-5c5b-4eed-8577-a45d3453cc45", + "e1f30f4f-9a4c-49ba-845c-c31e5f7ddc0f", + "fb903949-4a57-4c71-9739-46a0e037f251", + "096b8798-52e7-4f9e-bc30-adcb26f058b6" ] } \ No newline at end of file diff --git a/scripts/destroy_seed.py b/scripts/destroy_seed.py index d47d52f..5f40e45 100644 --- a/scripts/destroy_seed.py +++ b/scripts/destroy_seed.py @@ -143,12 +143,38 @@ def destroy_user_profiles(all_user_ids: list[str]) -> None: f"DELETE FROM user_profiles WHERE id IN ({lit});") +def destroy_notifications(all_user_ids: list[str]) -> None: + if not all_user_ids: + return + print(" notifications ...") + lit = ids_literal(all_user_ids) + psql("notification_db", "notification_user", "notification_db", + f"DELETE FROM notifications WHERE user_id IN ({lit});") + + def destroy_auth_credentials() -> None: print(" auth credentials (@sokodev.ug accounts) ...") psql("auth_db", "auth_user", "auth_db", "DELETE FROM auth_credentials WHERE email LIKE '%@sokodev.ug';") +def flush_api_caches() -> None: + """Flush Redis caches for produce (db 0) and blog (db 1) services. + Both services cache API responses in Redis; without this flush, deleted + seed data keeps appearing until TTL expiry (up to 10 min). + """ + print(" Redis API caches (produce db0, blog db1) ...") + for db in (0, 1): + cmd = [ + "docker", "compose", "-f", "docker-compose.yml", + "exec", "-T", "redis", + "redis-cli", "-n", str(db), "FLUSHDB", + ] + result = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True) + if result.returncode != 0 and result.stderr.strip(): + print(f" WARN (redis db{db}): {result.stderr.strip()[:120]}") + + def reset_ml_feature_store() -> None: print(" ML feature store (user_profiles, price_observations, interactions, coverage_gaps) ...") ml_compose = str(ROOT / "services" / "soko-ml" / "docker-compose.yml") @@ -193,10 +219,12 @@ def main() -> None: destroy_product_reviews(buyer_ids) destroy_listings(farmer_ids) destroy_messages(all_ids) + destroy_notifications(all_ids) destroy_payments(order_ids) destroy_orders(buyer_ids) destroy_user_profiles(all_ids) destroy_auth_credentials() + flush_api_caches() reset_ml_feature_store() MANIFEST.unlink(missing_ok=True) diff --git a/scripts/fetch-secrets.sh b/scripts/fetch-secrets.sh index bf218f0..3025ec9 100755 --- a/scripts/fetch-secrets.sh +++ b/scripts/fetch-secrets.sh @@ -58,6 +58,8 @@ INTERNAL_SECRET=$(s INTERNAL_SECRET) USER_SERVICE_URL=http://user_service:8002 REDIS_URL=redis://redis:6379/2 BOT_SECRET=$(s BOT_SECRET) +SENDGRID_API_KEY=$(s SENDGRID_API_KEY) +SENDGRID_FROM_EMAIL=$(s SENDGRID_FROM_EMAIL) EOF chmod 600 "$REPO_DIR/services/auth/.env" diff --git a/scripts/seed.py b/scripts/seed.py index 255b871..42ae97c 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -72,6 +72,40 @@ (9, 24), # Tumwesige Paul → Waiswa / Yellow Beans Bam 1 ] +# ── Crop image URLs (Unsplash CDN, publicly accessible) ────────────────────── +# Matched by lowercase keyword in the listing name. +# Used by seed_listing_images() to download → upload via Cloudinary. +CROP_IMAGES = { + "maize": "https://images.unsplash.com/photo-1601593346740-925612772716?w=800&q=80", + "sorghum": "https://images.unsplash.com/photo-1603048588665-791ca8aea617?w=800&q=80", + "millet": "https://images.unsplash.com/photo-1574323347407-f5e1ad6d020b?w=800&q=80", + "bean": "https://images.unsplash.com/photo-1553682538-a32cf88c62c7?w=800&q=80", + "potato": "https://images.unsplash.com/photo-1518977676601-b53f82aba655?w=800&q=80", + "tomato": "https://images.unsplash.com/photo-1592924357228-91a4daadcfea?w=800&q=80", + "matoke": "https://images.unsplash.com/photo-1571771894821-ce9b6c11b08e?w=800&q=80", + "banana": "https://images.unsplash.com/photo-1571771894821-ce9b6c11b08e?w=800&q=80", + "cassava": "https://images.unsplash.com/photo-1614668701670-d23b2c5aac01?w=800&q=80", +} +CROP_IMAGE_DEFAULT = "https://images.unsplash.com/photo-1500937386664-56d1dfef3854?w=800&q=80" + +# Per-post cover images indexed to match BLOG_POSTS order. +# These are stored directly as URLs in the blog DB (no Cloudinary). +BLOG_POST_IMAGES = [ + "https://images.unsplash.com/photo-1601593346740-925612772716?w=800&q=80", # maize/solar dryer + "https://images.unsplash.com/photo-1518977676601-b53f82aba655?w=800&q=80", # potatoes + "https://images.unsplash.com/photo-1553682538-a32cf88c62c7?w=800&q=80", # K132 yellow beans + "https://images.unsplash.com/photo-1592924357228-91a4daadcfea?w=800&q=80", # tomatoes / drip +] + + +def pick_crop_image(name: str) -> str: + n = name.lower() + for keyword, url in CROP_IMAGES.items(): + if keyword in n: + return url + return CROP_IMAGE_DEFAULT + + # ── Blog posts authored by seeded farmers ──────────────────────────────────── BLOG_POSTS = [ { @@ -785,6 +819,38 @@ def create_listings(farmers: list) -> list: return all_listings +# ── Phase 3b: Seed listing images ──────────────────────────────────────────── + +def seed_listing_images(listings: list) -> None: + """ + Registers a crop-appropriate Unsplash URL directly on each listing via the + internal /images/url endpoint — no Cloudinary upload, no interference with + how real users upload images. Skips gracefully if the endpoint is unavailable. + """ + import os + internal_secret = os.getenv("INTERNAL_SECRET", "") + if not internal_secret: + print("\n── Phase 3b: Skipping images (INTERNAL_SECRET not set) ─────────") + return + + print("\n── Phase 3b: Seeding listing images (direct URL, no Cloudinary) ─") + for listing in listings: + img_url = pick_crop_image(listing["name"]) + try: + resp = requests.post( + f"{PRODUCE}/listings/{listing['id']}/images/url", + json={"url": img_url}, + headers={"X-Internal-Secret": internal_secret}, + timeout=10, + ) + if resp.ok: + print(f" ✓ Image: {listing['name']}") + else: + print(f" ~ Image failed ({resp.status_code}): {listing['name']}") + except Exception as e: + print(f" ~ Skipped {listing['name']}: {e}") + + # ── Phase 4: Place orders ──────────────────────────────────────────────────── def place_orders(buyers: list, listings: list) -> list: @@ -898,17 +964,22 @@ def create_blog_posts(farmers: list) -> None: print("\n── Phase 6: Publishing farmer blog posts ────────────────────────") all_farmers = farmers # same order as FARMERS + EXTRA_FARMERS - for post_def in BLOG_POSTS: + for post_idx, post_def in enumerate(BLOG_POSTS): idx = post_def["farmer_idx"] if idx >= len(all_farmers): print(f" SKIP blog post (farmer_idx {idx} out of range)") continue f = all_farmers[idx] + cover = ( + BLOG_POST_IMAGES[post_idx] + if post_idx < len(BLOG_POST_IMAGES) + else BLOG_POST_IMAGES[-1] + ) payload = { "title": post_def["title"], "excerpt": post_def["excerpt"], - "image": "https://images.unsplash.com/photo-1628352081506-83c43123a6b9?w=800", + "image": cover, "category": post_def["category"], "tags": post_def["tags"], "body": post_def["body"], @@ -1061,6 +1132,7 @@ def print_summary(farmers: list, buyers: list, listings: list, order_ids: list): farmers, buyers = register_users() update_profiles(farmers) listings = create_listings(farmers) + seed_listing_images(listings) order_ids = place_orders(buyers, listings) create_conversations(buyers, listings) create_blog_posts(farmers) diff --git a/services/auth/app/core/config.py b/services/auth/app/core/config.py index b536669..8c0a150 100644 --- a/services/auth/app/core/config.py +++ b/services/auth/app/core/config.py @@ -17,6 +17,8 @@ class Settings(BaseSettings): REFRESH_TOKEN_EXPIRE_DAYS: int = 30 REDIS_URL: str = "redis://redis:6379/2" BOT_SECRET: str = "" + SENDGRID_API_KEY: str = "" + SENDGRID_FROM_EMAIL: str = "" class Config: env_file = ".env" diff --git a/services/auth/app/main.py b/services/auth/app/main.py index afcb820..0e1fc82 100644 --- a/services/auth/app/main.py +++ b/services/auth/app/main.py @@ -3,7 +3,7 @@ from starlette.middleware.sessions import SessionMiddleware from app.db.session import Base, engine from app.core.config import settings -from app.routers import auth, oauth, bot_auth +from app.routers import auth, oauth, bot_auth, alert @asynccontextmanager async def lifespan(app: FastAPI): @@ -18,8 +18,9 @@ async def lifespan(app: FastAPI): root_path="/auth", ) -app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) # ← must be here +app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) app.include_router(auth.router) app.include_router(oauth.router) -app.include_router(bot_auth.router) \ No newline at end of file +app.include_router(bot_auth.router) +app.include_router(alert.router) \ No newline at end of file diff --git a/services/auth/app/routers/alert.py b/services/auth/app/routers/alert.py new file mode 100644 index 0000000..3dd2940 --- /dev/null +++ b/services/auth/app/routers/alert.py @@ -0,0 +1,70 @@ +import logging +from typing import List + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.core.config import settings +from app.core.security import decode_token +from fastapi.security import OAuth2PasswordBearer + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/alert", tags=["alerts"]) + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login", auto_error=False) + +DEVELOPER_EMAIL = "andrewssuubi@gmail.com" +SENDGRID_URL = "https://api.sendgrid.com/v3/mail/send" + + +class UnsupportedCropPayload(BaseModel): + crops: List[str] + user_id: str + + +@router.post("/unsupported-crop", status_code=204) +async def unsupported_crop_alert( + payload: UnsupportedCropPayload, + token: str = Depends(oauth2_scheme), +): + if not token or not decode_token(token): + raise HTTPException(status_code=401, detail="Unauthorized") + + if not settings.SENDGRID_API_KEY or not settings.SENDGRID_FROM_EMAIL: + logger.warning("SendGrid not configured — skipping developer alert") + return + + crop_list = ", ".join(payload.crops) + body = { + "personalizations": [{"to": [{"email": DEVELOPER_EMAIL}]}], + "from": {"email": settings.SENDGRID_FROM_EMAIL}, + "subject": f"[Soko] Unsupported crop request: {crop_list}", + "content": [ + { + "type": "text/plain", + "value": ( + f"A farmer (user_id: {payload.user_id}) has specialties/listings " + f"that fall outside the current ML crop coverage:\n\n" + f" Crops: {crop_list}\n\n" + f"Consider adding a price model for these crops.\n\n" + f"-- Soko automated alert" + ), + } + ], + } + + try: + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.post( + SENDGRID_URL, + json=body, + headers={ + "Authorization": f"Bearer {settings.SENDGRID_API_KEY}", + "Content-Type": "application/json", + }, + ) + if resp.status_code not in (200, 202): + logger.warning(f"SendGrid returned {resp.status_code}: {resp.text}") + except Exception as exc: + logger.error(f"Developer alert email failed: {exc}") diff --git a/services/auth/app/routers/oauth.py b/services/auth/app/routers/oauth.py index 983a36a..22eaa3e 100644 --- a/services/auth/app/routers/oauth.py +++ b/services/auth/app/routers/oauth.py @@ -1,9 +1,13 @@ import logging +import secrets +from urllib.parse import urlencode + +import httpx from fastapi import APIRouter, BackgroundTasks, Cookie, Depends, HTTPException, Request from fastapi.responses import RedirectResponse, JSONResponse +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from sqlalchemy.orm import Session -from authlib.integrations.starlette_client import OAuth -from starlette.config import Config as StarletteConfig + from app.db.session import get_db from app.models.user import AuthCredential, UserRole as DBUserRole from app.core.security import ( @@ -14,46 +18,92 @@ ) from app.core.config import settings from app.schemas.auth import CompleteProfileRequest -import httpx - from .auth import _sync_user_to_ml logger = logging.getLogger(__name__) - router = APIRouter(tags=["OAuth"]) -starlette_config = StarletteConfig(environ={ - "GOOGLE_CLIENT_ID": settings.GOOGLE_CLIENT_ID, - "GOOGLE_CLIENT_SECRET": settings.GOOGLE_CLIENT_SECRET, -}) -oauth = OAuth(starlette_config) -oauth.register( - name="google", - server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": "openid email profile"}, -) +_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +_GOOGLE_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + + +def _make_state() -> str: + s = URLSafeTimedSerializer(settings.SECRET_KEY, salt="google-oauth-state") + return s.dumps(secrets.token_hex(16)) + + +def _verify_state(state: str) -> bool: + s = URLSafeTimedSerializer(settings.SECRET_KEY, salt="google-oauth-state") + try: + s.loads(state, max_age=300) + return True + except (BadSignature, SignatureExpired): + return False @router.get("/google/login") -async def google_login(request: Request): - return await oauth.google.authorize_redirect( - request, - settings.GOOGLE_REDIRECT_URI - ) +async def google_login(): + auth_url = _GOOGLE_AUTH_URL + "?" + urlencode({ + "client_id": settings.GOOGLE_CLIENT_ID, + "redirect_uri": settings.GOOGLE_REDIRECT_URI, + "response_type": "code", + "scope": "openid email profile", + "state": _make_state(), + "access_type": "online", + }) + return RedirectResponse(url=auth_url) @router.get("/google/callback") async def google_callback(request: Request, db: Session = Depends(get_db)): - # ── 1. Exchange code for Google token + # ── 1. Verify CSRF state (HMAC-signed, no storage needed) + state = request.query_params.get("state") + if not state or not _verify_state(state): + logger.error("OAuth state invalid or expired") + raise HTTPException(status_code=400, detail="Invalid OAuth state") + + error = request.query_params.get("error") + if error: + raise HTTPException(status_code=400, detail=f"Google OAuth error: {error}") + + code = request.query_params.get("code") + if not code: + raise HTTPException(status_code=400, detail="Missing authorization code") + + # ── 2. Exchange code for access token try: - google_token = await oauth.google.authorize_access_token(request) + async with httpx.AsyncClient() as client: + token_resp = await client.post( + _GOOGLE_TOKEN_URL, + data={ + "code": code, + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": settings.GOOGLE_REDIRECT_URI, + "grant_type": "authorization_code", + }, + timeout=10.0, + ) + token_resp.raise_for_status() + token_data = token_resp.json() except Exception as e: - logger.error(f"Google OAuth token exchange failed: {e}") + logger.error(f"Google token exchange failed: {e}") raise HTTPException(status_code=400, detail="Google OAuth failed") - google_user = google_token.get("userinfo") - if not google_user: + # ── 3. Fetch user info + try: + async with httpx.AsyncClient() as client: + info_resp = await client.get( + _GOOGLE_INFO_URL, + headers={"Authorization": f"Bearer {token_data['access_token']}"}, + timeout=10.0, + ) + info_resp.raise_for_status() + google_user = info_resp.json() + except Exception as e: + logger.error(f"Google userinfo fetch failed: {e}") raise HTTPException(status_code=400, detail="Could not fetch user info from Google") email = google_user.get("email") @@ -63,7 +113,7 @@ async def google_callback(request: Request, db: Session = Depends(get_db)): if not email: raise HTTPException(status_code=400, detail="Google account has no email address") - # ── 2. Check for existing user + # ── 4. Look up existing user user = db.query(AuthCredential).filter(AuthCredential.email == email).first() if user: @@ -71,16 +121,18 @@ async def google_callback(request: Request, db: Session = Depends(get_db)): if user.hashed_password and not user.oauth_provider: raise HTTPException( status_code=409, - detail="An account with this email already exists. Please log in with your password." + detail="An account with this email already exists. Please log in with your password.", ) access_token = create_access_token(str(user.id), user.role.value, user.email) refresh_token = create_refresh_token(str(user.id)) if user.is_profile_complete: - # Returning OAuth user — profile already done, hand off token to frontend + # Redirect to /auth/sign-in (S3-routed in CloudFront) so the SPA picks up + # the token. /auth/google/callback routes to EC2, which would re-enter this + # callback handler and fail state verification. response = RedirectResponse( - url=f"{settings.FRONTEND_URL}/auth/google/callback?access_token={access_token}" + url=f"{settings.FRONTEND_URL}/auth/sign-in?access_token={access_token}" ) _set_auth_cookies(response, access_token, refresh_token) return response @@ -104,7 +156,7 @@ async def google_callback(request: Request, db: Session = Depends(get_db)): ) return response - # ── 3. New user — skeleton credential, no commit yet + # ── 5. New user — create skeleton credential user = AuthCredential( email=email, hashed_password=None, @@ -116,7 +168,6 @@ async def google_callback(request: Request, db: Session = Depends(get_db)): db.add(user) db.flush() - # ── 4. Issue short-lived setup token (carries Google data) setup_token = create_setup_token( user_id=str(user.id), email=email, @@ -124,22 +175,25 @@ async def google_callback(request: Request, db: Session = Depends(get_db)): avatar_url=avatar_url, ) - # ── 5. Commit skeleton, redirect to profile completion db.commit() db.refresh(user) response = RedirectResponse(url=f"{settings.FRONTEND_URL}/auth/complete-profile") response.set_cookie( - key="setup_token", - value=setup_token, - httponly=True, - secure=True, - samesite="lax", - max_age=60 * 10, + key="setup_token", value=setup_token, + httponly=True, secure=True, samesite="lax", max_age=60 * 10, ) return response +@router.get("/complete-profile") +async def complete_profile_page(): + # CloudFront routes this path to EC2 so POST can reach the API. + # For GET (SPA page load), return 404 — CloudFront's custom_error_response + # catches it and serves index.html so the React router handles the route. + raise HTTPException(status_code=404, detail="Not found") + + @router.post("/complete-profile") async def complete_profile( body: CompleteProfileRequest, @@ -147,7 +201,6 @@ async def complete_profile( db: Session = Depends(get_db), setup_token: str | None = Cookie(default=None), ): - # ── 1. Validate setup token from HttpOnly cookie if not setup_token: raise HTTPException(status_code=401, detail="Setup session missing or expired. Please sign in again.") @@ -160,12 +213,10 @@ async def complete_profile( name = payload["name"] avatar_url = payload.get("avatar_url") - # ── 2. Load the skeleton credential user = db.query(AuthCredential).filter(AuthCredential.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="Account not found. Please try signing in again.") - # ── 3. Guard against double submission if user.is_profile_complete: access_token = create_access_token(str(user.id), user.role.value, user.email) refresh_token = create_refresh_token(str(user.id)) @@ -174,16 +225,14 @@ async def complete_profile( _clear_setup_cookie(response) return response - # ── 4. Apply form data user.role = body.role user.is_profile_complete = True db.flush() - # ── 5. Create full profile in User Service try: async with httpx.AsyncClient() as client: res = await client.post( - f"{settings.USER_SERVICE_URL}/users", + f"{settings.USER_SERVICE_URL}/", json={ "id": str(user.id), "email": email, @@ -208,11 +257,9 @@ async def complete_profile( db.rollback() raise HTTPException(status_code=503, detail="Could not create user profile. Please try again.") - # ── 6. Issue real tokens, clear setup cookie access_token = create_access_token(str(user.id), user.role.value, user.email) refresh_token = create_refresh_token(str(user.id)) - # Sync new OAuth user to ML feature store so recommendations work immediately background_tasks.add_task( _sync_user_to_ml, str(user.id), @@ -224,8 +271,8 @@ async def complete_profile( ) response = JSONResponse({ - "message": "Profile complete", - "role": user.role.value, + "message": "Profile complete", + "role": user.role.value, "access_token": access_token, }) _set_auth_cookies(response, access_token, refresh_token) @@ -249,4 +296,4 @@ def _set_auth_cookies(response, access_token: str, refresh_token: str) -> None: def _clear_setup_cookie(response) -> None: - response.delete_cookie(key="setup_token", httponly=True, secure=True, samesite="lax") \ No newline at end of file + response.delete_cookie(key="setup_token", httponly=True, secure=True, samesite="lax") diff --git a/services/produce/app/routers/images.py b/services/produce/app/routers/images.py index f4c52fb..d2e91b4 100644 --- a/services/produce/app/routers/images.py +++ b/services/produce/app/routers/images.py @@ -1,7 +1,9 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi import APIRouter, Depends, Header, HTTPException, UploadFile, File +from pydantic import BaseModel from sqlalchemy.orm import Session +from app.core.config import settings from app.core.dependencies import farmer_only, get_current_user_id from app.db.database import get_db from app.helpers.builders import build_listing_out @@ -76,4 +78,39 @@ def delete_image( db.delete(image) db.commit() - return {"deleted": True} \ No newline at end of file + return {"deleted": True} + + +# ── Internal seed endpoint ───────────────────────────────────────────────────── +# Registers an external image URL without a Cloudinary upload. +# Only reachable with the INTERNAL_SECRET header — never called by real users. +# public_id is left null so the delete handler skips the Cloudinary call cleanly. + +class _SeedImageBody(BaseModel): + url: str + + +@router.post("/{listing_id}/images/url", response_model=ListingOut) +def seed_image_url( + listing_id: str, + body: _SeedImageBody, + internal_secret: str | None = Header(None, alias="X-Internal-Secret"), + db: Session = Depends(get_db), +): + if internal_secret != settings.INTERNAL_SECRET: + raise HTTPException(status_code=403, detail="Forbidden") + + listing = db.query(Listing).filter(Listing.id == uuid.UUID(listing_id)).first() + if not listing: + raise HTTPException(status_code=404, detail="Listing not found") + + existing = len(listing.images) + db.add(ListingImage( + listing_id=listing.id, + url=body.url, + public_id=None, + order=existing, + )) + db.commit() + db.refresh(listing) + return build_listing_out(listing) \ No newline at end of file diff --git a/services/soko-ml/docker-compose.yml b/services/soko-ml/docker-compose.yml index 0329381..fe51078 100644 --- a/services/soko-ml/docker-compose.yml +++ b/services/soko-ml/docker-compose.yml @@ -244,13 +244,13 @@ services: - SERVICE_NAME=ml-gateway-service depends_on: price-prediction-service: - condition: service_healthy + condition: service_started recommendation-service: - condition: service_healthy + condition: service_started location-service: - condition: service_healthy + condition: service_started data-ingestion-service: - condition: service_healthy + condition: service_started healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 15s @@ -331,6 +331,8 @@ services: condition: service_healthy redis: condition: service_healthy + db-init: + condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8003/health"] interval: 15s diff --git a/services/soko-ml/location-service/src/cache.py b/services/soko-ml/location-service/src/cache.py index 05d77c7..d0961c8 100644 --- a/services/soko-ml/location-service/src/cache.py +++ b/services/soko-ml/location-service/src/cache.py @@ -78,9 +78,9 @@ async def invalidate_farmer_distances(farmer_id: str) -> None: # ── Route cache ─────────────────────────────────────────────────────────────── -async def get_route(farmer_id: str, crop: str, quantity: float) -> Optional[dict]: +async def get_route(farmer_id: str, crop: str, quantity: Optional[float]) -> Optional[dict]: redis = await get_redis() - key = f"route:{farmer_id}:{crop}:{int(quantity)}" + key = f"route:{farmer_id}:{crop}:{int(quantity or 0)}" try: val = await redis.get(key) return json.loads(val) if val else None @@ -89,9 +89,9 @@ async def get_route(farmer_id: str, crop: str, quantity: float) -> Optional[dict return None -async def set_route(farmer_id: str, crop: str, quantity: float, result: dict) -> None: +async def set_route(farmer_id: str, crop: str, quantity: Optional[float], result: dict) -> None: redis = await get_redis() - key = f"route:{farmer_id}:{crop}:{int(quantity)}" + key = f"route:{farmer_id}:{crop}:{int(quantity or 0)}" try: await redis.setex(key, ROUTE_TTL, json.dumps(result, cls=_Encoder)) except Exception as exc: diff --git a/services/soko-ml/location-service/src/market_router.py b/services/soko-ml/location-service/src/market_router.py index 62c8a42..a3f8e97 100644 --- a/services/soko-ml/location-service/src/market_router.py +++ b/services/soko-ml/location-service/src/market_router.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) POSTGRES_DSN = os.getenv("POSTGRES_DSN", "postgresql://soko_ml:changeme@soko-ml-db:5432/soko_ml_db") -PRICE_SERVICE_URL = os.getenv("PRICE_SERVICE_URL", "http://ml-gateway-service:8080") +PRICE_SERVICE_URL = os.getenv("PRICE_SERVICE_URL", "http://ml-gateway-service:8000") DEFAULT_MAX_KM = float(os.getenv("DEFAULT_MAX_DISTANCE_KM", "150")) _pool: Optional[asyncpg.Pool] = None