diff --git a/Backend/.env-example b/Backend/.env-example index 18e42cd..f1b4686 100644 --- a/Backend/.env-example +++ b/Backend/.env-example @@ -7,4 +7,5 @@ GROQ_API_KEY= SUPABASE_URL= SUPABASE_KEY= GEMINI_API_KEY= -YOUTUBE_API_KEY= \ No newline at end of file +YOUTUBE_API_KEY= +SUPABASE_JWT_AUDIENCE= \ No newline at end of file diff --git a/Backend/app/main.py b/Backend/app/main.py index 86d892a..ef9258e 100644 --- a/Backend/app/main.py +++ b/Backend/app/main.py @@ -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 @@ -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("/") diff --git a/Backend/app/models/models.py b/Backend/app/models/models.py index 56681ab..ba545d5 100644 --- a/Backend/app/models/models.py +++ b/Backend/app/models/models.py @@ -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) diff --git a/Backend/app/routes/notification.py b/Backend/app/routes/notification.py new file mode 100644 index 0000000..abcbfe9 --- /dev/null +++ b/Backend/app/routes/notification.py @@ -0,0 +1,244 @@ +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 +import time + +# 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) + +supabase_url = os.getenv("SUPABASE_URL") +supabase_key = os.getenv("SUPABASE_KEY") +if not supabase_url or not supabase_key: + logger.error("SUPABASE_URL and SUPABASE_KEY environment variables must be set") + raise RuntimeError("Missing required Supabase configuration") +supabase: Client = create_client(supabase_url, supabase_key) + +def insert_notification_to_supabase(notification_dict, max_retries=3, delay=0.5): + for attempt in range(1, max_retries + 1): + 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"Supabase insert attempt {attempt} failed for notification {notification_dict['id']}: {e}") + if attempt < max_retries: + time.sleep(delay) + logger.error(f"Failed to insert notification {notification_dict['id']} into Supabase after {max_retries} attempts.") + # Optionally, add to a persistent 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("Authorization header received") + 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("RS256 verification succeeded.") + 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("HS256 verification succeeded.") + 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) +): + try: + 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 + ] + except Exception as e: + logger.error(f"Failed to fetch notifications: {e}") + return JSONResponse(status_code=500, content={"error": "Failed to fetch notifications."}) + +@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.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) +): + if not ids or not isinstance(ids, list) or len(ids) == 0: + logger.warning("Delete notifications called with empty or invalid ids list.") + return JSONResponse(status_code=400, content={"error": "No notification IDs provided for deletion."}) + try: + result = await db.execute( + delete(Notification) + .where(Notification.user_id == user["id"]) + .where(Notification.id.in_(ids)) + ) + await db.commit() + if result.rowcount == 0: + logger.warning(f"No notifications deleted for user {user['id']} with ids: {ids}") + return JSONResponse(status_code=404, content={"error": "No notifications found to delete."}) + return + except Exception as e: + logger.error(f"Failed to delete notifications: {e}") + await db.rollback() + return JSONResponse(status_code=500, content={"error": "Failed to delete notifications."}) + +@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) +): + if not ids or not isinstance(ids, list) or len(ids) == 0: + logger.warning("Mark notifications read called with empty or invalid ids list.") + return JSONResponse(status_code=400, content={"error": "No notification IDs provided to mark as read."}) + try: + result = await db.execute( + update(Notification) + .where(Notification.user_id == user["id"]) + .where(Notification.id.in_(ids)) + .values(is_read=True) + ) + await db.commit() + if result.rowcount == 0: + logger.warning(f"No notifications marked as read for user {user['id']} with ids: {ids}") + return JSONResponse(status_code=404, content={"error": "No notifications found to mark as read."}) + return {"success": True} + except Exception as e: + logger.error(f"Failed to mark notifications as read: {e}") + await db.rollback() + return JSONResponse(status_code=500, content={"error": "Failed to mark notifications as read."}) \ No newline at end of file diff --git a/Frontend/env-example b/Frontend/env-example index 4ce57da..5359523 100644 --- a/Frontend/env-example +++ b/Frontend/env-example @@ -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 \ No newline at end of file +VITE_YOUTUBE_API_KEY=your-youtube-api-key-here +VITE_API_BASE_URL=https://your-api-url.com \ No newline at end of file diff --git a/Frontend/src/App.css b/Frontend/src/App.css index e69de29..8daeebc 100644 --- a/Frontend/src/App.css +++ b/Frontend/src/App.css @@ -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; +} diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index be41d2e..24bd501 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -1,10 +1,10 @@ -import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import { BrowserRouter as Router, Routes, Route, useLocation } from "react-router-dom"; import { useState, useEffect } from "react"; -import HomePage from "../src/pages/HomePage"; -import DashboardPage from "../src/pages/DashboardPage"; -import SponsorshipsPage from "../src/pages/Sponsorships"; -import CollaborationsPage from "../src/pages/Collaborations"; -import MessagesPage from "../src/pages/Messages"; +import HomePage from "./pages/HomePage"; +import DashboardPage from "./pages/DashboardPage"; +import SponsorshipsPage from "./pages/Sponsorships"; +import CollaborationsPage from "./pages/Collaborations"; +import MessagesPage from "./pages/Messages"; import LoginPage from "./pages/Login"; import SignupPage from "./pages/Signup"; import ForgotPasswordPage from "./pages/ForgotPassword"; @@ -12,13 +12,119 @@ import ResetPasswordPage from "./pages/ResetPassword"; import Contracts from "./pages/Contracts"; import Analytics from "./pages/Analytics"; import RoleSelection from "./pages/RoleSelection"; - -import { AuthProvider } from "./context/AuthContext"; +import NotificationsPage from "./pages/Notifications"; +import { AuthProvider, useAuth } from "./context/AuthContext"; import ProtectedRoute from "./components/ProtectedRoute"; import PublicRoute from "./components/PublicRoute"; import Dashboard from "./pages/Brand/Dashboard"; import BasicDetails from "./pages/BasicDetails"; import Onboarding from "./components/Onboarding"; +import { UserNav } from "./components/user-nav"; +import { supabase } from "./utils/supabase"; + +function AppContent() { + const { user, isAuthenticated } = useAuth(); + const [unreadCount, setUnreadCount] = useState(0); + const location = useLocation(); + + useEffect(() => { + const fetchUnreadCount = async () => { + if (!isAuthenticated) return; + const { data } = await supabase.auth.getSession(); + const accessToken = data.session?.access_token; + if (!accessToken) return; + try { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; + const res = await fetch(`${apiBaseUrl}/notifications/`, { + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json" + }, + }); + if (!res.ok) return; + const notifications = await res.json(); + const unread = notifications.filter((n: any) => !n.is_read).length; + setUnreadCount(unread); + } catch (err) { + setUnreadCount(0); + } + }; + fetchUnreadCount(); + }, [isAuthenticated, user]); + + return ( + <> + {location.pathname !== "/notifications" && } + + {/* Public Routes */} + } /> + + + + } /> + + + + } /> + } /> + Brand Onboarding (Coming Soon)} /> + Creator Onboarding (Coming Soon)} /> + } /> + } /> + + + + } /> + } /> + } /> + + + + } /> + + + + } /> + {/* Protected Routes*/} + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + ); +} function App() { const [isLoading, setIsLoading] = useState(true); @@ -44,87 +150,7 @@ function App() { return ( - - {/* Public Routes */} - } /> - - - - } /> - - - - } /> - } /> - Brand Onboarding (Coming Soon)} /> - Creator Onboarding (Coming Soon)} /> - } /> - } /> - - - - } /> - } /> - } /> - - - - } /> - - {/* Protected Routes*/} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - + ); diff --git a/Frontend/src/components/user-nav.tsx b/Frontend/src/components/user-nav.tsx index 9c4939f..778881f 100644 --- a/Frontend/src/components/user-nav.tsx +++ b/Frontend/src/components/user-nav.tsx @@ -14,7 +14,7 @@ import { import { useAuth } from "../context/AuthContext"; import { Link } from "react-router-dom"; -export function UserNav() { +export function UserNav({ unreadCount }: { unreadCount?: number }) { const { user, isAuthenticated, logout } = useAuth(); const [avatarError, setAvatarError] = useState(false); @@ -63,6 +63,19 @@ export function UserNav() { Dashboard + + + Notifications + {typeof unreadCount === 'number' && unreadCount > 0 && ( + + {unreadCount} + + )} + + Profile Settings diff --git a/Frontend/src/pages/Notifications.tsx b/Frontend/src/pages/Notifications.tsx new file mode 100644 index 0000000..05c5c98 --- /dev/null +++ b/Frontend/src/pages/Notifications.tsx @@ -0,0 +1,349 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "../components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog"; +import { format } from "date-fns"; +import { useAuth } from "../context/AuthContext"; +import { supabase } from "../utils/supabase"; +import { ArrowLeft } from "lucide-react"; +import { UserNav } from "../components/user-nav"; +import { useNavigate } from "react-router-dom"; +import "../App.css"; // For custom animation +import { isValid, parseISO } from "date-fns"; + +export interface Notification { + id: string; + title: string; + message: string; + is_read: boolean; + category?: string; + created_at: string; + type?: string; + link?: string; +} + +export interface NotificationDialog { + id: string; + title: string; + message: string; + is_read: boolean; + category?: string; + created_at: string; + type?: string; + link?: string; +} + +const categoryIcons: Record = { + welcome: "👋", + message: "💬", + campaign: "📢", + default: "🔔", +}; + +export default function NotificationsPage() { + const { user, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + const [notifications, setNotifications] = useState([]); + const [selected, setSelected] = useState([]); + const [openDialog, setOpenDialog] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [accessToken, setAccessToken] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + const [successMsg, setSuccessMsg] = useState(""); + + // Fetch the user's access token on mount + useEffect(() => { + const getToken = async () => { + const { data } = await supabase.auth.getSession(); + setAccessToken(data.session?.access_token || null); + }; + getToken(); + }, []); + + // Fetch notifications from backend + useEffect(() => { + if (!accessToken || !user) return; + const fetchNotifications = async () => { + setLoading(true); + setError(""); + try { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; + const res = await fetch(`${apiBaseUrl}/notifications/`, { + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json" + }, + }); + if (res.status === 401) throw new Error("Unauthorized. Please log in again."); + if (!res.ok) throw new Error("Failed to fetch notifications"); + const data = await res.json(); + setNotifications(data); + setError(""); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("Error fetching notifications"); + } + } finally { + setLoading(false); + } + }; + fetchNotifications(); + }, [accessToken, user]); + + useEffect(() => { + if (!user) return; + + // Subscribe to new notifications for this user + const channel = supabase + .channel('notifications-realtime') + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'notifications', + filter: `user_id=eq.${user.id}`, + }, + (payload) => { + setNotifications((prev) => { + // Avoid duplicates + if (prev.some((n) => n.id === payload.new.id)) return prev; + return [payload.new, ...prev]; + }); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [user]); + + const toggleSelect = (id: string) => { + setSelected((prev) => + prev.includes(id) ? prev.filter((sid) => sid !== id) : [...prev, id] + ); + }; + + const selectAll = () => { + setSelected(notifications.map((n) => n.id)); + }; + + const deselectAll = () => { + setSelected([]); + }; + + const deleteSelected = async () => { + setActionLoading(true); + setError(""); + setSuccessMsg(""); + try { + await fetch(`${import.meta.env.VITE_API_BASE_URL}/notifications/`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + }, + body: JSON.stringify(selected), + }); + setNotifications((prev) => prev.filter((n) => !selected.includes(n.id))); + setSelected([]); + setSuccessMsg("Selected notifications deleted."); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to delete notifications"); + } finally { + setActionLoading(false); + } + }; + + const deleteAll = async () => { + setActionLoading(true); + setError(""); + setSuccessMsg(""); + try { + await fetch(`${import.meta.env.VITE_API_BASE_URL}/notifications/`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + }, + body: JSON.stringify(notifications.map((n) => n.id)), + }); + setNotifications([]); + setSelected([]); + setSuccessMsg("All notifications deleted."); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to delete notifications"); + } finally { + setActionLoading(false); + } + }; + + const getIcon = (category: string) => categoryIcons[category] || categoryIcons.default; + + const handleOpenDialog = async (n: Notification) => { + setError(""); + setSuccessMsg(""); + if (!n.is_read) { + setActionLoading(true); + try { + await fetch(`${import.meta.env.VITE_API_BASE_URL}/notifications/mark-read`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + }, + body: JSON.stringify([n.id]), + }); + setNotifications((prev) => prev.map((notif) => notif.id === n.id ? { ...notif, is_read: true } : notif)); + setSuccessMsg("Notification marked as read."); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to mark notification as read"); + } finally { + setActionLoading(false); + } + } + setOpenDialog(n); + }; + + const dismissError = () => setError(""); + const dismissSuccess = () => setSuccessMsg(""); + + if (!isAuthenticated) { + return
Please log in to view notifications.
; + } + + return ( +
+ {/* Animated SVG or blob background */} + + + + {/* Sticky Header */} +
+
+ +

+ Notifications +

+ +
+
+ {/* Main Content */} +
+
+ {error && ( +
+ {error} + +
+ )} + {successMsg && ( +
+ {successMsg} + +
+ )} +
+ + + + +
+ {loading ? ( +
+ + + + + Loading... +
+ ) : ( +
    + {notifications.length === 0 && ( +
  • + {/* Custom SVG or mascot here */} + 🔔 + No notifications + You're all caught up! 🎉 +
  • + )} + {notifications.map((n, i) => ( +
  • handleOpenDialog(n)} + > + { e.stopPropagation(); toggleSelect(n.id); }} + className="form-checkbox h-5 w-5 text-purple-600" + aria-checked={selected.includes(n.id)} + aria-label={`Select notification: ${n.title}`} + disabled={actionLoading} + tabIndex={0} + onKeyDown={e => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + toggleSelect(n.id); + } + }} + /> + {getIcon(n.category || "default")} +
    +
    {n.title}
    +
    {n.message}
    +
    +
    + {(() => { + const dateObj = typeof n.created_at === 'string' ? parseISO(n.created_at) : new Date(n.created_at); + return isValid(dateObj) + ? format(dateObj, "PPpp") + : "Invalid date"; + })()} +
    + {!n.is_read && } +
  • + ))} +
+ )} + setOpenDialog(null)}> + + {openDialog && ( + <> + + {getIcon(openDialog.category || "default")} + {openDialog.title} + + {format(new Date(openDialog.created_at), "PPpp")} + + +
{openDialog.message}
+ + + + + )} +
+
+
+
+
+ ); +} \ No newline at end of file