""" Content Service — Blog posts Uses Supabase if configured, falls back to demo data """ import logging import os import re from typing import Optional, List logger = logging.getLogger(__name__) _supabase = None DEMO_BLOGS = [ { "slug": "structural-shifts-global-bond-markets", "title": "The Structural Shifts in Global Bond Markets: What You Need to Know", "excerpt": "As central banks pivot their strategies, we analyze the architectural changes in sovereign debt markets and how it impacts the everyday investor's portfolio balance.", "category": "Macroeconomics", "tags": ["bonds", "central banks", "global markets"], "read_time_minutes": 12, "published": True, "featured": True, "view_count": 2840, "created_at": "2024-06-24T10:00:00Z", "image_url": None, }, { "slug": "rbi-monetary-policy-decoded", "title": "RBI Monetary Policy Decoded", "excerpt": "A granular look at the latest MPC meeting outcomes and future projections.", "category": "Strategy", "tags": ["RBI", "monetary policy", "MPC"], "read_time_minutes": 8, "published": True, "featured": False, "view_count": 1420, "created_at": "2024-06-22T08:00:00Z", "image_url": None, }, { "slug": "tax-planning-101", "title": "Tax Planning 101", "excerpt": "Practical steps to optimize your financial year-end with maximum efficiency.", "category": "Personal Finance", "tags": ["tax", "planning", "investment"], "read_time_minutes": 6, "published": True, "featured": False, "view_count": 3210, "created_at": "2024-06-20T09:00:00Z", "image_url": None, }, { "slug": "digital-rupee-progress-pitfalls", "title": "The Digital Rupee: Progress and Potential Pitfalls", "excerpt": "India's CBDC journey — where we are, what's working, and what could go wrong.", "category": "Macroeconomics", "tags": ["CBDC", "digital rupee", "RBI"], "read_time_minutes": 7, "published": True, "featured": False, "view_count": 980, "created_at": "2024-06-18T10:00:00Z", "image_url": None, }, { "slug": "why-midcap-funds-finding-favor-2024", "title": "Why Mid-Cap Funds are Finding Favor in 2024", "excerpt": "The mid-cap resurgence explained with data and analyst insights.", "category": "Investment", "tags": ["mutual funds", "mid-cap", "equity"], "read_time_minutes": 9, "published": True, "featured": False, "view_count": 1760, "created_at": "2024-06-15T10:00:00Z", "image_url": None, }, { "slug": "understanding-yield-curves-simple-terms", "title": "Understanding Yield Curves in Simple Terms", "excerpt": "Bond yields, inverted curves, and what they signal for the economy.", "category": "Education", "tags": ["bonds", "yield curve", "education"], "read_time_minutes": 5, "published": True, "featured": False, "view_count": 2100, "created_at": "2024-06-10T10:00:00Z", "image_url": None, }, ] def init_supabase(): global _supabase url = os.getenv("SUPABASE_URL") key = os.getenv("SUPABASE_SERVICE_KEY") if url and key: try: from supabase import create_client _supabase = create_client(url, key) logger.info("Content service: Supabase connected") except Exception as e: logger.warning(f"Supabase init failed: {e} — using demo data") else: logger.info("Content service: No Supabase config — using demo data") def get_blogs(limit: int = 20, offset: int = 0, category: Optional[str] = None) -> List[dict]: if _supabase: try: q = _supabase.table("blog_posts").select( "id,slug,title,excerpt,category,tags,read_time_minutes,image_url,published,created_at,featured,view_count" ).eq("published", True).order("created_at", desc=True) if category: q = q.eq("category", category) return q.range(offset, offset + limit - 1).execute().data or [] except Exception as e: logger.warning(f"Supabase blog list error: {e}") blogs = DEMO_BLOGS if category: blogs = [b for b in blogs if b["category"] == category] return blogs[offset: offset + limit] def get_featured() -> Optional[dict]: if _supabase: try: r = _supabase.table("blog_posts").select("*").eq("published", True).eq("featured", True).order("created_at", desc=True).limit(1).execute() return (r.data or [None])[0] except Exception: pass return next((b for b in DEMO_BLOGS if b.get("featured")), DEMO_BLOGS[0]) def get_recent(limit: int = 3) -> List[dict]: if _supabase: try: r = _supabase.table("blog_posts").select("id,slug,title,category,created_at").eq("published", True).order("created_at", desc=True).limit(limit).execute() return r.data or [] except Exception: pass return [{"slug": b["slug"], "title": b["title"], "category": b["category"], "created_at": b["created_at"]} for b in DEMO_BLOGS[:limit]] def get_blog(slug: str) -> Optional[dict]: if _supabase: try: r = _supabase.table("blog_posts").select("*").eq("slug", slug).single().execute() return r.data except Exception: pass return next((b for b in DEMO_BLOGS if b["slug"] == slug), None) def get_categories() -> List[str]: blogs = get_blogs(limit=100) return sorted(set(b["category"] for b in blogs)) def create_blog( *, title: str, content: str, category: str, tags: Optional[List[str]] = None, excerpt: Optional[str] = None, image_url: Optional[str] = None, featured: bool = False, published: bool = True, author_id: Optional[str] = None, author_email: Optional[str] = None, ) -> dict: if not _supabase: raise ValueError("Supabase is not configured") clean_title = title.strip() clean_content = content.strip() clean_category = category.strip() clean_excerpt = (excerpt or clean_content[:220]).strip() clean_tags = [t.strip() for t in (tags or []) if t and t.strip()] read_time_minutes = max(1, round(len(clean_content.split()) / 200)) slug_base = _slugify(clean_title) slug = _unique_slug(slug_base) payload = { "slug": slug, "title": clean_title, "excerpt": clean_excerpt, "content": clean_content, "category": clean_category, "tags": clean_tags, "read_time_minutes": read_time_minutes, "image_url": image_url.strip() if image_url else None, "published": published, "featured": featured, "view_count": 0, "author_id": author_id, "author_email": author_email, } try: if featured: _supabase.table("blog_posts").update({"featured": False}).eq("featured", True).execute() response = _supabase.table("blog_posts").insert(payload).execute() created = (response.data or [None])[0] if not created: raise ValueError("Blog insert failed") return created except Exception as e: logger.warning(f"Supabase blog create error: {e}") raise ValueError("Failed to create blog post") def get_user_blogs(author_id: str, is_admin: bool = False) -> List[dict]: if not _supabase: return DEMO_BLOGS # demo: no author_id on demo posts, show all q = _supabase.table("blog_posts").select("*").order("created_at", desc=True) if not is_admin: q = q.eq("author_id", author_id) return q.execute().data or [] def update_blog(slug: str, author_id: str, role: str, **fields) -> dict: if not _supabase: raise ValueError("Supabase is not configured") post = get_blog(slug) if not post: raise ValueError("not found") if role != "admin" and post.get("author_id") != author_id: raise PermissionError("forbidden") updates = {k: v for k, v in fields.items() if v is not None} if not updates: raise ValueError("No fields to update") # Normalize string fields the same way create does for f in ("title", "category", "excerpt"): if f in updates and isinstance(updates[f], str): updates[f] = updates[f].strip() if "content" in updates: updates["content"] = updates["content"].strip() updates["read_time_minutes"] = max(1, round(len(updates["content"].split()) / 200)) if "image_url" in updates: updates["image_url"] = updates["image_url"].strip() if updates["image_url"] else None if "tags" in updates: updates["tags"] = [t.strip() for t in updates["tags"] if t and t.strip()] try: if updates.get("featured"): _supabase.table("blog_posts").update({"featured": False}).eq("featured", True).execute() r = _supabase.table("blog_posts").update(updates).eq("slug", slug).execute() updated = (r.data or [None])[0] if not updated: raise ValueError("Update failed") return updated except (ValueError, PermissionError): raise except Exception as e: logger.warning(f"Supabase blog update error: {e}") raise ValueError("Failed to update blog post") def delete_blog(slug: str, author_id: str, role: str) -> None: if not _supabase: raise ValueError("Supabase is not configured") post = get_blog(slug) if not post: raise ValueError("not found") if role != "admin" and post.get("author_id") != author_id: raise PermissionError("forbidden") try: _supabase.table("blog_posts").delete().eq("slug", slug).execute() except Exception as e: logger.warning(f"Supabase blog delete error: {e}") raise ValueError("Failed to delete blog post") def upload_blog_image(file_bytes: bytes, content_type: str, author_id: str) -> str: import uuid if not _supabase: raise ValueError("Storage not configured") ext = content_type.split("/")[1] if ext == "jpeg": ext = "jpg" path = f"{author_id}/{uuid.uuid4()}.{ext}" try: _supabase.storage.from_("blog-images").upload(path, file_bytes, {"content-type": content_type}) return _supabase.storage.from_("blog-images").get_public_url(path) except Exception as e: logger.warning(f"Supabase storage upload error: {e}") raise ValueError("Storage upload failed") def _slugify(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") return slug or "untitled-post" def _unique_slug(base_slug: str) -> str: slug = base_slug counter = 2 while _slug_exists(slug): slug = f"{base_slug}-{counter}" counter += 1 return slug def _slug_exists(slug: str) -> bool: if not _supabase: return any(b["slug"] == slug for b in DEMO_BLOGS) try: response = _supabase.table("blog_posts").select("slug").eq("slug", slug).limit(1).execute() return bool(response.data) except Exception: return False