From e0b83796d06232e3e2f7e50bb1a93ff787097044 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Thu, 21 May 2026 16:43:31 +0300 Subject: [PATCH 01/16] Your commit message From 012ca4d7a1702cdf05197cb06e4241df005a6d74 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Thu, 21 May 2026 18:41:54 +0300 Subject: [PATCH 02/16] feat: populate live crop images in seed data Adds Phase 3b to the seed script: downloads a crop-appropriate Unsplash image for each listing and uploads it via the existing multipart endpoint (Cloudinary). Blog posts now get per-post cover images instead of one shared placeholder. Both phases fail gracefully so the rest of the seed continues if Cloudinary is not configured or a download times out. --- scripts/seed.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/scripts/seed.py b/scripts/seed.py index 255b871..d292cec 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,45 @@ def create_listings(farmers: list) -> list: return all_listings +# ── Phase 3b: Seed listing images ──────────────────────────────────────────── + +def seed_listing_images(listings: list) -> None: + """ + Downloads a crop-appropriate image from Unsplash and uploads it to each + listing via the existing multipart endpoint (which stores to Cloudinary). + Skips gracefully if the download or upload fails. + """ + print("\n── Phase 3b: Seeding listing images ────────────────────────────") + headers_common = {"User-Agent": "Soko-Seed/1.0 (dev)"} + + for listing in listings: + img_url = pick_crop_image(listing["name"]) + try: + dl = requests.get(img_url, timeout=15, headers=headers_common, allow_redirects=True) + if not dl.ok: + print(f" ~ Download failed ({dl.status_code}): {listing['name']}") + continue + + content_type = dl.headers.get("content-type", "image/jpeg").split(";")[0].strip() + ext_map = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"} + ext = ext_map.get(content_type, "jpg") + if content_type not in ext_map: + content_type = "image/jpeg" + + resp = requests.post( + f"{PRODUCE}/listings/{listing['id']}/images", + files={"files": (f"photo.{ext}", dl.content, content_type)}, + headers=farmer_headers(listing["farmer_id"]), + timeout=30, + ) + if resp.ok: + print(f" ✓ Image: {listing['name']}") + else: + print(f" ~ Upload 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 +971,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 +1139,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) From 601a37797f46c368464bec7c0ab35102411c1fff Mon Sep 17 00:00:00 2001 From: the-icemann Date: Thu, 21 May 2026 19:04:29 +0300 Subject: [PATCH 03/16] feat: seed listing images via internal URL endpoint, bypass Cloudinary Adds POST /listings/{id}/images/url (INTERNAL_SECRET-gated) that stores an external URL directly in listing_images with public_id=null. The existing Cloudinary upload path for real users is completely untouched. Seed Phase 3b now calls this endpoint with crop-matched Unsplash URLs instead of downloading and re-uploading through Cloudinary. --- scripts/seed.py | 35 +++++++++------------- services/produce/app/routers/images.py | 41 ++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/scripts/seed.py b/scripts/seed.py index d292cec..42ae97c 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -823,37 +823,30 @@ def create_listings(farmers: list) -> list: def seed_listing_images(listings: list) -> None: """ - Downloads a crop-appropriate image from Unsplash and uploads it to each - listing via the existing multipart endpoint (which stores to Cloudinary). - Skips gracefully if the download or upload fails. + 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. """ - print("\n── Phase 3b: Seeding listing images ────────────────────────────") - headers_common = {"User-Agent": "Soko-Seed/1.0 (dev)"} + 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: - dl = requests.get(img_url, timeout=15, headers=headers_common, allow_redirects=True) - if not dl.ok: - print(f" ~ Download failed ({dl.status_code}): {listing['name']}") - continue - - content_type = dl.headers.get("content-type", "image/jpeg").split(";")[0].strip() - ext_map = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"} - ext = ext_map.get(content_type, "jpg") - if content_type not in ext_map: - content_type = "image/jpeg" - resp = requests.post( - f"{PRODUCE}/listings/{listing['id']}/images", - files={"files": (f"photo.{ext}", dl.content, content_type)}, - headers=farmer_headers(listing["farmer_id"]), - timeout=30, + 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" ~ Upload failed ({resp.status_code}): {listing['name']}") + print(f" ~ Image failed ({resp.status_code}): {listing['name']}") except Exception as e: print(f" ~ Skipped {listing['name']}: {e}") 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 From 97d9e096795a6abf43008025afea2fc35b1274e2 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Thu, 21 May 2026 19:12:26 +0300 Subject: [PATCH 04/16] fix: destroy_seed now cleans up notification_db Seed interactions (orders, messages) generate notification rows for seeded users. destroy_seed was missing the notification_db step, leaving orphaned rows after teardown. Added destroy_notifications() targeting user_id IN seeded IDs and wired it between destroy_messages and destroy_payments. --- scripts/destroy_seed.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/destroy_seed.py b/scripts/destroy_seed.py index d47d52f..5f78887 100644 --- a/scripts/destroy_seed.py +++ b/scripts/destroy_seed.py @@ -143,6 +143,15 @@ 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", @@ -193,6 +202,7 @@ 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) From e77b55562ae6032d928446f269afe30676f80463 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Thu, 21 May 2026 22:20:08 +0300 Subject: [PATCH 05/16] Trying to solve cloudFront header issue --- nginx/nginx.conf | 7 ++++--- scripts/destroy_seed.py | 18 ++++++++++++++++++ services/auth/app/main.py | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) 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/destroy_seed.py b/scripts/destroy_seed.py index 5f78887..5f40e45 100644 --- a/scripts/destroy_seed.py +++ b/scripts/destroy_seed.py @@ -158,6 +158,23 @@ def destroy_auth_credentials() -> None: "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") @@ -207,6 +224,7 @@ def main() -> None: 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/services/auth/app/main.py b/services/auth/app/main.py index 706adbe..465c637 100644 --- a/services/auth/app/main.py +++ b/services/auth/app/main.py @@ -18,7 +18,7 @@ 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, https_only=True) # ← must be here app.include_router(auth.router) app.include_router(oauth.router) \ No newline at end of file From 617efec5e34aafe56ff81610a85467e5f9300764 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Thu, 21 May 2026 22:37:17 +0300 Subject: [PATCH 06/16] fix: replace session-based OAuth state with cookie-based state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authlib's session-backed state storage was unreliable behind the nginx + CloudFront proxy chain — the session cookie was not surviving the login→Google→callback roundtrip, causing a MismatchingStateError on every Google OAuth attempt. New approach: generate a cryptographically random state on /google/login, store it in a short-lived _oauth_state httpOnly cookie (same pattern as setup_token which already works), and validate it on the callback. Code exchange is done directly via httpx against Google's token and userinfo endpoints, removing the authlib session dependency entirely. Also fixes the returning-user redirect: complete-profile users now go to /home instead of back to the profile form, and incomplete-profile returning users get a fresh setup_token. --- services/auth/app/main.py | 2 +- services/auth/app/routers/oauth.py | 144 +++++++++++++++++++---------- 2 files changed, 96 insertions(+), 50 deletions(-) diff --git a/services/auth/app/main.py b/services/auth/app/main.py index 465c637..3b215cf 100644 --- a/services/auth/app/main.py +++ b/services/auth/app/main.py @@ -18,7 +18,7 @@ async def lifespan(app: FastAPI): root_path="/auth", ) -app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, https_only=True) # ← must be here +app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) app.include_router(auth.router) app.include_router(oauth.router) \ No newline at end of file diff --git a/services/auth/app/routers/oauth.py b/services/auth/app/routers/oauth.py index a6d6a42..579a168 100644 --- a/services/auth/app/routers/oauth.py +++ b/services/auth/app/routers/oauth.py @@ -1,9 +1,12 @@ 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 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,7 +17,6 @@ ) from app.core.config import settings from app.schemas.auth import CompleteProfileRequest -import httpx from .auth import _sync_user_to_ml @@ -22,38 +24,81 @@ 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" @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(): + state = secrets.token_urlsafe(32) + 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": state, + "access_type": "online", + }) + response = RedirectResponse(url=auth_url) + response.set_cookie( + key="_oauth_state", value=state, + httponly=True, secure=True, samesite="lax", + max_age=300, path="/", ) + return response @router.get("/google/callback") async def google_callback(request: Request, db: Session = Depends(get_db)): - # ── 1. Exchange code for Google token + # ── 1. CSRF state check (cookie vs URL param) + state_cookie = request.cookies.get("_oauth_state") + state_param = request.query_params.get("state") + if not state_cookie or state_cookie != state_param: + logger.error("OAuth state mismatch — possible CSRF or stale session") + 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,24 +108,38 @@ 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: if user.oauth_provider is None and user.hashed_password: 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.", ) - # Returning OAuth user — pass token to SPA via query param so the - # frontend zustand store can pick it up without relying on httpOnly cookies. - access_token = create_access_token(str(user.id), user.role.value, user.email) - return RedirectResponse( - url=f"{settings.FRONTEND_URL}/auth/complete-profile?access_token={access_token}" + 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)) + response = RedirectResponse(url=f"{settings.FRONTEND_URL}/home") + _set_auth_cookies(response, access_token, refresh_token) + return response + + # Returning user with incomplete profile — re-issue setup token + setup_token = create_setup_token( + user_id=str(user.id), + email=user.email, + name=name, + avatar_url=avatar_url, ) + 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, + ) + return response - # ── 3. New user — skeleton credential, no commit yet + # ── 5. New user — create skeleton credential user = AuthCredential( email=email, hashed_password=None, @@ -92,7 +151,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, @@ -100,18 +158,13 @@ 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 @@ -123,7 +176,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.") @@ -136,12 +188,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)) @@ -150,12 +200,10 @@ 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( @@ -184,11 +232,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), @@ -200,8 +246,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) @@ -209,7 +255,7 @@ async def complete_profile( return response -# Helpers +# ── Helpers ────────────────────────────────────────────────────────────────── def _set_auth_cookies(response, access_token: str, refresh_token: str) -> None: response.set_cookie( @@ -225,4 +271,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") From 795aa314a07c3f894da5ac049dc5e15e7be826c4 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Thu, 21 May 2026 22:45:11 +0300 Subject: [PATCH 07/16] fix: use HMAC-signed stateless OAuth state, drop cookie dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _oauth_state cookie set on the /google/login 302 redirect was also being dropped before reaching the browser (same CloudFront/nginx issue as the original session cookie). No cookie or session can be reliably set on a redirect response in this proxy chain. New approach: embed the CSRF state directly in the state parameter as a URLSafeTimedSerializer-signed token (itsdangerous, already a dep). The state is self-verifiable on the callback — no storage of any kind needed. 5-minute expiry enforced by itsdangerous timestamp. --- services/auth/app/routers/oauth.py | 37 +++++++++++++++++------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/services/auth/app/routers/oauth.py b/services/auth/app/routers/oauth.py index 579a168..3459cf2 100644 --- a/services/auth/app/routers/oauth.py +++ b/services/auth/app/routers/oauth.py @@ -5,6 +5,7 @@ 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 app.db.session import get_db @@ -17,11 +18,9 @@ ) from app.core.config import settings from app.schemas.auth import CompleteProfileRequest - from .auth import _sync_user_to_ml logger = logging.getLogger(__name__) - router = APIRouter(tags=["OAuth"]) _GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" @@ -29,34 +28,40 @@ _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(): - state = secrets.token_urlsafe(32) 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": state, + "state": _make_state(), "access_type": "online", }) - response = RedirectResponse(url=auth_url) - response.set_cookie( - key="_oauth_state", value=state, - httponly=True, secure=True, samesite="lax", - max_age=300, path="/", - ) - return response + return RedirectResponse(url=auth_url) @router.get("/google/callback") async def google_callback(request: Request, db: Session = Depends(get_db)): - # ── 1. CSRF state check (cookie vs URL param) - state_cookie = request.cookies.get("_oauth_state") - state_param = request.query_params.get("state") - if not state_cookie or state_cookie != state_param: - logger.error("OAuth state mismatch — possible CSRF or stale session") + # ── 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") From 2ed3c03d3fdd62b38d4b3fc67957d7666d5306bd Mon Sep 17 00:00:00 2001 From: the-icemann Date: Fri, 22 May 2026 10:07:25 +0300 Subject: [PATCH 08/16] fix: correct user service URL in complete-profile endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was calling /users on the user service which doesn't exist — the internal create_user route is at POST / (no prefix). Password registration already used the correct URL (USER_SERVICE_URL/). --- services/auth/app/routers/oauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/auth/app/routers/oauth.py b/services/auth/app/routers/oauth.py index 3459cf2..695e60d 100644 --- a/services/auth/app/routers/oauth.py +++ b/services/auth/app/routers/oauth.py @@ -212,7 +212,7 @@ async def complete_profile( 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, From b8eacd779883c30548ae5db40cb8903f89721754 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Fri, 22 May 2026 10:29:12 +0300 Subject: [PATCH 09/16] fix: route /auth/complete-profile to EC2 so POST reaches auth service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloudFront's /auth/complete-profile behavior was targeting S3 with GET/HEAD only — POST from the profile form was blocked at the CDN layer, returning 403/405, never reaching the auth service. Change the behavior to target EC2 with all methods allowed. Add a GET handler in the auth service that returns 404 so CloudFront's custom_error_response still serves index.html for SPA page loads. --- infrastructure/cloudfront.tf | 16 ++++++++++------ services/auth/app/routers/oauth.py | 8 ++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/infrastructure/cloudfront.tf b/infrastructure/cloudfront.tf index efc14dc..a1068c1 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/services/auth/app/routers/oauth.py b/services/auth/app/routers/oauth.py index 695e60d..dfae537 100644 --- a/services/auth/app/routers/oauth.py +++ b/services/auth/app/routers/oauth.py @@ -174,6 +174,14 @@ async def google_callback(request: Request, db: Session = Depends(get_db)): 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, From 32091c5963507dacca646ee9c9aa44debfbd9930 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Sat, 23 May 2026 14:35:44 +0300 Subject: [PATCH 10/16] fix: redirect returning Google users to /auth/sign-in instead of /auth/google/callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /auth/google/callback matches the CloudFront /auth/* → EC2 behavior, so the browser would loop back to the backend callback handler with no state param, causing the 'Invalid OAuth state' error. /auth/sign-in is explicitly routed to S3 so the SPA loads and loginWithToken can run. --- services/auth/app/routers/oauth.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/auth/app/routers/oauth.py b/services/auth/app/routers/oauth.py index b44cc29..22eaa3e 100644 --- a/services/auth/app/routers/oauth.py +++ b/services/auth/app/routers/oauth.py @@ -128,9 +128,11 @@ async def google_callback(request: Request, db: Session = Depends(get_db)): 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 From a5bee1045fa27393f58ed8fe16dd08404cec67c5 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Sat, 23 May 2026 21:16:26 +0300 Subject: [PATCH 11/16] fix: ensure soko-ml-bridge network exists before compose up, relax gateway deps Both stacks declare soko-ml-bridge as external but nothing created it, causing docker compose up to fail silently. Add explicit network create step (idempotent) before both compose invocations. Also relax ml-gateway-service depends_on from service_healthy to service_started so the gateway starts even if an individual ML service is slow to pass health checks, with restart:unless-stopped handling retries. --- .github/workflows/deploy.yml | 5 ++++- services/soko-ml/docker-compose.yml | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9fd89f3..71d7307 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 @@ -50,7 +53,7 @@ jobs: echo "=== Verifying health ===" sleep 15 - for port in 8001 8002 8003 8004 8005 8007 8008 8009; do + 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/services/soko-ml/docker-compose.yml b/services/soko-ml/docker-compose.yml index 0329381..63df592 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 From b8beb23b04dfd0bb537b6b297c4fe51c339c0677 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Sat, 23 May 2026 22:51:12 +0300 Subject: [PATCH 12/16] fix: handle None quantity_kg in location-service route cache int(None) crashed before the try/except block when quantity_kg is not provided. Farmers without listings send no quantity, so the backend must tolerate None. Use quantity or 0 as the cache key slot so null-quantity requests still get routed (Tier 2/3 generic response). --- services/soko-ml/location-service/src/cache.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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: From 922251d22d0f8a96cd645e31ebab55e837082961 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Sat, 23 May 2026 23:14:46 +0300 Subject: [PATCH 13/16] feat: developer alert email for unsupported crop requests Add POST /auth/alert/unsupported-crop endpoint to the auth service. When a farmer's specialty or listing falls outside ML crop coverage, the frontend calls this endpoint which sends a SendGrid email to andrewssuubi@gmail.com listing the unsupported crop names and user ID. Endpoint is JWT-protected and silently no-ops when SendGrid is not configured. --- scripts/fetch-secrets.sh | 2 + services/auth/app/core/config.py | 2 + services/auth/app/main.py | 5 ++- services/auth/app/routers/alert.py | 70 ++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 services/auth/app/routers/alert.py diff --git a/scripts/fetch-secrets.sh b/scripts/fetch-secrets.sh index eb01afe..a9481d5 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/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 602d9c0..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): @@ -22,4 +22,5 @@ async def lifespan(app: FastAPI): 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}") From f02b82288ea8f852eac37e074bfa51e433dd50ab Mon Sep 17 00:00:00 2001 From: the-icemann Date: Sun, 24 May 2026 17:17:09 +0300 Subject: [PATCH 14/16] fix: remove hardcoded BOT_SECRET from auth_service environment The inline environment: value overrode the BOT_SECRET loaded from Secrets Manager via env_file, so the real secret was never used. Removing it lets the env_file value take effect. --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) 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 From 7781bb47f5a88ea4de880c222cb936c9d2825521 Mon Sep 17 00:00:00 2001 From: the-icemann Date: Sat, 30 May 2026 11:41:27 +0300 Subject: [PATCH 15/16] fix: inject Cloudinary credentials into produce and blog service envs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch-secrets.sh was writing only INTERNAL_SECRET for produce and blog, leaving CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET unset. Both services declare these as required fields in their Settings class, so they crashed on startup in production — causing the marketplace 502. --- scripts/fetch-secrets.sh | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/fetch-secrets.sh b/scripts/fetch-secrets.sh index a9481d5..a6bc30c 100755 --- a/scripts/fetch-secrets.sh +++ b/scripts/fetch-secrets.sh @@ -114,12 +114,30 @@ NOTIFICATION_SERVICE_URL=http://notification_service:8007 EOF chmod 600 "$REPO_DIR/services/message/.env" -# ── User / Produce / Order / Blog — only need INTERNAL_SECRET ───────────────── -for svc in user produce order blog; do +# ── User / Order — only need INTERNAL_SECRET ────────────────────────────────── +for svc in user order; do cat > "$REPO_DIR/services/$svc/.env" < "$REPO_DIR/services/produce/.env" < "$REPO_DIR/services/blog/.env" < Date: Mon, 1 Jun 2026 22:05:56 +0300 Subject: [PATCH 16/16] fix(ml): location-service ordering, gateway port fallback, deploy health timing - location-service now waits for db-init to complete before starting, preventing a race where market_registry table may not exist on fresh deploys - fix hardcoded fallback port in market_router.py (8080 -> 8000) - increase deploy health check sleep from 15s to 90s so ML stack status is accurate in CI logs (Kafka needs ~90s to become healthy) --- .github/workflows/deploy.yml | 2 +- services/soko-ml/docker-compose.yml | 2 ++ services/soko-ml/location-service/src/market_router.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 71d7307..0f00f43 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -52,7 +52,7 @@ jobs: docker image prune -f echo "=== Verifying health ===" - sleep 15 + 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}" diff --git a/services/soko-ml/docker-compose.yml b/services/soko-ml/docker-compose.yml index 63df592..fe51078 100644 --- a/services/soko-ml/docker-compose.yml +++ b/services/soko-ml/docker-compose.yml @@ -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/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