Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Backend/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ GROQ_API_KEY=
SUPABASE_URL=
SUPABASE_KEY=
GEMINI_API_KEY=
YOUTUBE_API_KEY=
YOUTUBE_API_KEY=
SUPABASE_JWT_AUDIENCE=
2 changes: 2 additions & 0 deletions Backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .routes.post import router as post_router
from .routes.chat import router as chat_router
from .routes.match import router as match_router
from .routes import notification
from sqlalchemy.exc import SQLAlchemyError
import logging
import os
Expand Down Expand Up @@ -56,6 +57,7 @@ async def lifespan(app: FastAPI):
app.include_router(match_router)
app.include_router(ai.router)
app.include_router(ai.youtube_router)
app.include_router(notification.router)


@app.get("/")
Expand Down
15 changes: 15 additions & 0 deletions Backend/app/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,18 @@ class SponsorshipPayment(Base):
brand = relationship(
"User", foreign_keys=[brand_id], back_populates="brand_payments"
)


# Notification Table
class Notification(Base):
__tablename__ = "notifications"

id = Column(String, primary_key=True, default=generate_uuid)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
type = Column(String, nullable=True)
title = Column(String, nullable=False)
message = Column(Text, nullable=False)
link = Column(String, nullable=True)
is_read = Column(Boolean, default=False)
category = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
210 changes: 210 additions & 0 deletions Backend/app/routes/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
from fastapi import APIRouter, Depends, HTTPException, status, Body, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, update
from typing import List
from app.models.models import Notification
from app.db.db import get_db
import jwt
import os
from supabase import create_client, Client
from datetime import datetime, timezone
import uuid
import logging
from fastapi.responses import JSONResponse

supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_KEY")
supabase: Client = create_client(supabase_url, supabase_key)

# Set up logging
logger = logging.getLogger("notification")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
formatter = logging.Formatter('[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
handler.setFormatter(formatter)
if not logger.hasHandlers():
logger.addHandler(handler)

def insert_notification_to_supabase(notification_dict):
try:
supabase.table("notifications").insert(notification_dict).execute()
logger.info(f"Notification {notification_dict['id']} inserted into Supabase.")
return True
except Exception as e:
logger.error(f"Failed to insert notification {notification_dict['id']} into Supabase: {e}")
# Optionally, add to a retry queue here
return False

router = APIRouter(prefix="/notifications", tags=["notifications"])

# Use the Supabase JWT public key for RS256 verification
SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET")
SUPABASE_JWT_PUBLIC_KEY = os.environ.get("SUPABASE_JWT_PUBLIC_KEY")
SUPABASE_JWT_AUDIENCE = os.environ.get("SUPABASE_JWT_AUDIENCE", "padhvzdttdlxbvldvdhz")

# Dependency to verify JWT and extract user id
# Make sure to set SUPABASE_JWT_PUBLIC_KEY in your environment (from Supabase Project Settings > API > JWT Verification Key)
def get_current_user(authorization: str = Header(...)):
logger.info(f"Authorization header received: {authorization}")
if not authorization or not authorization.startswith("Bearer "):
logger.warning("Missing or invalid Authorization header")
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
token = authorization.split(" ", 1)[1]
# Try RS256 first
try:
if SUPABASE_JWT_PUBLIC_KEY:
logger.info("Trying RS256 verification...")
payload = jwt.decode(
token,
SUPABASE_JWT_PUBLIC_KEY,
algorithms=["RS256"],
audience=SUPABASE_JWT_AUDIENCE,
)
logger.info(f"RS256 verification succeeded. JWT payload: {payload}")
user_id = payload.get("sub")
if not user_id:
logger.error("No user_id in payload (RS256)")
raise HTTPException(status_code=401, detail="Invalid token payload: no user id (RS256)")
return {"id": user_id}
else:
logger.warning("No RS256 public key set, skipping RS256 check.")
except Exception as e:
logger.error(f"RS256 verification failed: {str(e)}")
# Try HS256 as fallback
try:
if SUPABASE_JWT_SECRET:
logger.info("Trying HS256 verification...")
payload = jwt.decode(
token,
SUPABASE_JWT_SECRET,
algorithms=["HS256"],
audience=SUPABASE_JWT_AUDIENCE,
)
logger.info(f"HS256 verification succeeded. JWT payload: {payload}")
user_id = payload.get("sub")
if not user_id:
logger.error("No user_id in payload (HS256)")
raise HTTPException(status_code=401, detail="Invalid token payload: no user id (HS256)")
return {"id": user_id}
else:
logger.warning("No HS256 secret set, skipping HS256 check.")
except Exception as e:
logger.error(f"HS256 verification failed: {str(e)}")
logger.error("Both RS256 and HS256 verification failed.")
raise HTTPException(status_code=401, detail="Invalid token: could not verify with RS256 or HS256.")

@router.get("/", response_model=List[dict])
async def get_notifications(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
result = await db.execute(
select(Notification)
.where(Notification.user_id == user["id"])
.order_by(Notification.created_at.desc())
)
notifs = result.scalars().all()
return [
{
"id": n.id,
"title": n.title,
"message": n.message,
"is_read": n.is_read,
"category": n.category,
"created_at": n.created_at,
"type": n.type,
"link": n.link,
}
for n in notifs
]

@router.post("/", status_code=201)
async def create_notification(
notification: dict = Body(...),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
try:
# Generate a UUID for the notification
notif_id = str(uuid.uuid4())
now_utc = datetime.now(timezone.utc)
created_at = notification.get("created_at")
if created_at:
# Parse and convert to UTC if needed
try:
created_at = datetime.fromisoformat(created_at)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
else:
created_at = created_at.astimezone(timezone.utc)
except Exception as e:
logger.warning(f"Invalid created_at format, using now: {e}")
created_at = now_utc
else:
created_at = now_utc

notif_obj = Notification(
id=notif_id,
user_id=user["id"],
type=notification.get("type"),
title=notification["title"],
message=notification["message"],
link=notification.get("link"),
is_read=notification.get("is_read", False),
category=notification.get("category"),
created_at=created_at,
)
db.add(notif_obj)
await db.commit()
await db.refresh(notif_obj)
# Insert into Supabase for realtime
notif_dict = {
"id": notif_obj.id,
"user_id": notif_obj.user_id,
"type": notif_obj.type,
"title": notif_obj.title,
"message": notif_obj.message,
"link": notif_obj.link,
"is_read": notif_obj.is_read,
"category": notif_obj.category,
"created_at": notif_obj.created_at.astimezone(timezone.utc).isoformat() if notif_obj.created_at else None,
}
supabase_ok = insert_notification_to_supabase(notif_dict)
if not supabase_ok:
logger.error(f"Notification {notif_id} saved locally but failed to push to Supabase.")
return JSONResponse(status_code=202, content={"error": "Notification saved locally, but failed to push to Supabase. Realtime delivery may be delayed."})
logger.info(f"Notification {notif_id} created for user {user['id']}.")
return notif_dict
except Exception as e:
logger.error(f"Failed to create notification: {e}")
await db.rollback()
return JSONResponse(status_code=500, content={"error": f"Failed to create notification: {str(e)}"})

@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
async def delete_notifications(
ids: List[str] = Body(...),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
await db.execute(
delete(Notification)
.where(Notification.user_id == user["id"])
.where(Notification.id.in_(ids))
)
await db.commit()
return

@router.patch("/mark-read", status_code=status.HTTP_200_OK)
async def mark_notifications_read(
ids: List[str] = Body(...),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
await db.execute(
update(Notification)
.where(Notification.user_id == user["id"])
.where(Notification.id.in_(ids))
.values(is_read=True)
)
await db.commit()
return {"success": True}
3 changes: 2 additions & 1 deletion Frontend/env-example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
VITE_YOUTUBE_API_KEY=your-youtube-api-key-here
VITE_YOUTUBE_API_KEY=your-youtube-api-key-here
VITE_API_BASE_URL=https://your-api-url.com
7 changes: 7 additions & 0 deletions Frontend/src/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fade-in-up 0.5s both;
}
Loading