From 5011260e17171689b39cedab2f15ff4191011edf Mon Sep 17 00:00:00 2001 From: osctoss Date: Fri, 29 May 2026 16:33:21 +0530 Subject: [PATCH 01/45] chore: add uploads folder with .gitkeep to ensure folder structure is tracked --- .gitignore | 3 ++- uploads/.gitkeep | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 uploads/.gitkeep diff --git a/.gitignore b/.gitignore index 29c7eca..50ebd38 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,8 @@ CHANGELOG.md # yarn.lock # Uploads / media -uploads/ +uploads/* +!uploads/.gitkeep # Test coverage coverage/ \ No newline at end of file diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..901a08c --- /dev/null +++ b/uploads/.gitkeep @@ -0,0 +1 @@ +# Keep directory tracked by Git while contents are ignored From 2581a0c923be921a9a5df6696d341066fed30103 Mon Sep 17 00:00:00 2001 From: Abhishek Shukla Date: Fri, 29 May 2026 16:36:53 +0530 Subject: [PATCH 02/45] Latest FAQ updates --- client/src/App.jsx | 53 +- client/src/components/BackToTop.jsx | 25 + client/src/components/Breadcrumb.jsx | 20 + client/src/components/ErrorBoundary.jsx | 39 + client/src/components/Nav.jsx | 125 ++- client/src/components/SkeletonLoader.jsx | 45 + client/src/components/Toast.jsx | 65 ++ client/src/components/UpvoteButton.jsx | 11 +- client/src/pages/AddFAQPage.jsx | 2 + client/src/pages/FAQEditPage.jsx | 160 ++++ client/src/pages/FAQPage.jsx | 90 +- client/src/pages/NotificationsPage.jsx | 10 +- client/src/pages/ProfilePage.jsx | 2 + client/src/pages/RTQDetailPage.jsx | 210 +++++ client/src/pages/RTQPage.jsx | 209 +++-- client/src/pages/RaiseQuestionPage.jsx | 42 +- client/src/pages/SeniorDashboard.jsx | 24 + client/src/pages/StudentDashboard.jsx | 56 +- client/src/pages/TrackQuestionPage.jsx | 3 +- client/src/pages/UserListPage.jsx | 447 ++++++--- client/src/pages/UserProfilePage.jsx | 127 +++ client/src/pages/WorkingHistoryPage.jsx | 10 +- client/src/services/dashboard.service.js | 7 + rag-engine/decision-engine/decision.tree.js | 63 +- rag-engine/embedding/embedder.js | 20 + rag-engine/embedding/transformer.js | 52 ++ rag-engine/embedding/vocab-faq.json | 2 +- rag-engine/vectorDB/faq-vector.js | 33 +- rag-engine/vectorDB/rtq-vector.js | 32 +- server/package-lock.json | 847 ++++++++++++++++++ server/package.json | 1 + server/src/app.js | 2 + server/src/controllers/faq.controller.js | 21 +- server/src/controllers/rtq.controller.js | 53 +- server/src/create-admin.js | 61 ++ server/src/routes/dashboard.routes.js | 25 + .../src/services/vector/collection.service.js | 4 +- .../src/services/vector/embedding.service.js | 113 +-- .../src/services/vector/faq.vector.service.js | 32 +- .../src/services/vector/rtq.vector.service.js | 53 +- .../services/vector/transformer.service.js | 199 ++-- server/src/test-qdrant.js | 72 ++ server/src/test-similarity.js | 55 ++ 43 files changed, 2942 insertions(+), 580 deletions(-) create mode 100644 client/src/components/BackToTop.jsx create mode 100644 client/src/components/Breadcrumb.jsx create mode 100644 client/src/components/ErrorBoundary.jsx create mode 100644 client/src/components/SkeletonLoader.jsx create mode 100644 client/src/components/Toast.jsx create mode 100644 client/src/pages/FAQEditPage.jsx create mode 100644 client/src/pages/RTQDetailPage.jsx create mode 100644 client/src/pages/UserProfilePage.jsx create mode 100644 client/src/services/dashboard.service.js create mode 100644 rag-engine/embedding/transformer.js create mode 100644 server/src/create-admin.js create mode 100644 server/src/routes/dashboard.routes.js create mode 100644 server/src/test-qdrant.js create mode 100644 server/src/test-similarity.js diff --git a/client/src/App.jsx b/client/src/App.jsx index b9f6876..08f5f0f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,18 +1,22 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { useAuth } from './context/AuthContext'; import Nav from './components/Nav'; +import ErrorBoundary from './components/ErrorBoundary'; +import { ToastProvider } from './components/Toast'; -// Pages import LoginPage from './pages/LoginPage'; import SignupPage from './pages/SignupPage'; import FAQPage from './pages/FAQPage'; +import FAQEditPage from './pages/FAQEditPage'; import RTQPage from './pages/RTQPage'; +import RTQDetailPage from './pages/RTQDetailPage'; import StudentDashboard from './pages/StudentDashboard'; import SeniorDashboard from './pages/SeniorDashboard'; import AddFAQPage from './pages/AddFAQPage'; import RaiseQuestionPage from './pages/RaiseQuestionPage'; import ProfilePage from './pages/ProfilePage'; import UserListPage from './pages/UserListPage'; +import UserProfilePage from './pages/UserProfilePage'; import TrackQuestionPage from './pages/TrackQuestionPage'; import WorkingHistoryPage from './pages/WorkingHistoryPage'; import NotificationsPage from './pages/NotificationsPage'; @@ -42,7 +46,6 @@ function PublicOnly({ children }) { return children; } -// Dashboard renders correct page based on role function DashboardRoute() { const { user, loading } = useAuth(); if (loading) return ; @@ -53,7 +56,7 @@ function DashboardRoute() { const PUBLIC_PATHS = ['/login', '/signup']; -export default function App() { +function AppLayout() { const location = useLocation(); const { user, refreshUser } = useAuth(); const isPublic = PUBLIC_PATHS.includes(location.pathname); @@ -62,30 +65,34 @@ export default function App() {
{user && !isPublic &&
); -} \ No newline at end of file +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/client/src/components/BackToTop.jsx b/client/src/components/BackToTop.jsx new file mode 100644 index 0000000..f15eb3a --- /dev/null +++ b/client/src/components/BackToTop.jsx @@ -0,0 +1,25 @@ +import { useState, useEffect } from 'react'; + +export default function BackToTop() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const onScroll = () => setVisible(window.scrollY > 300); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + if (!visible) return null; + + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/Breadcrumb.jsx b/client/src/components/Breadcrumb.jsx new file mode 100644 index 0000000..6d98368 --- /dev/null +++ b/client/src/components/Breadcrumb.jsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router-dom'; + +export default function Breadcrumb({ items }) { + if (!items || items.length === 0) return null; + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/ErrorBoundary.jsx b/client/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..1667420 --- /dev/null +++ b/client/src/components/ErrorBoundary.jsx @@ -0,0 +1,39 @@ +import { Component } from 'react'; + +export default class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, info) { + console.error('[ErrorBoundary]', error, info); + } + + render() { + if (this.state.hasError) { + return ( +
+
+
⚠️
+

Something went wrong

+

+ {this.state.error?.message || 'An unexpected error occurred.'} +

+ +
+
+ ); + } + return this.props.children; + } +} \ No newline at end of file diff --git a/client/src/components/Nav.jsx b/client/src/components/Nav.jsx index 5b79b0d..62e5bdd 100644 --- a/client/src/components/Nav.jsx +++ b/client/src/components/Nav.jsx @@ -1,12 +1,73 @@ +import { useState, useEffect, useRef } from 'react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; -import { useQP } from '../context/QPContext'; -import { useEffect } from 'react'; +import notificationService from '../services/notification.service'; +import { timeAgo } from '../utils/helpers'; export default function Nav({ refreshUser }) { const { user, logout } = useAuth(); const navigate = useNavigate(); const location = useLocation(); + const [unreadCount, setUnreadCount] = useState(0); + const [recentNotifs, setRecentNotifs] = useState([]); + const [bellOpen, setBellOpen] = useState(false); + const bellRef = useRef(null); + + useEffect(() => { + if (!user) return; + const fetchNotifs = async () => { + try { + const [count, notifs] = await Promise.all([ + notificationService.getUnreadCount(), + notificationService.getNotifications().catch(() => ({ notifications: [] })) + ]); + setUnreadCount(count.count || 0); + const all = notifs.notifications || notifs || []; + setRecentNotifs(all.slice(0, 5)); + } catch { + // silent + } + }; + fetchNotifs(); + const interval = setInterval(fetchNotifs, 30000); + return () => clearInterval(interval); + }, [user]); + + useEffect(() => { + const handleClick = (e) => { + if (bellRef.current && !bellRef.current.contains(e.target)) { + setBellOpen(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + useEffect(() => { + if (bellOpen) { + notificationService.getNotifications() + .then(data => { + const all = data.notifications || data || []; + setRecentNotifs(all.slice(0, 5)); + }) + .catch(() => {}); + } + }, [bellOpen, unreadCount]); + + const handleMarkAllRead = async (e) => { + e.stopPropagation(); + try { + const all = recentNotifs; + await Promise.all( + all.filter(n => !n.read).map(n => notificationService.markAsRead(n._id)) + ); + setUnreadCount(0); + setRecentNotifs(prev => prev.map(n => ({ ...n, read: true }))); + } catch { + // silent + } + setBellOpen(false); + }; const handleLogout = () => { logout(); @@ -25,7 +86,6 @@ export default function Nav({ refreshUser }) { ...(user?.role === 'senior' || user?.role === 'admin' ? [{ to: '/add-faq', label: 'Add FAQ' }, { to: '/history', label: 'History' }] : []), - { to: '/notifications', label: 'Notifications' }, { to: '/users', label: 'Users' }, { to: '/profile', label: 'Profile' }, ]; @@ -50,6 +110,65 @@ export default function Nav({ refreshUser }) { ))}
+
+ + + {bellOpen && ( +
+
+ Notifications + {unreadCount > 0 && ( + + )} +
+
+ {recentNotifs.length === 0 ? ( +
No notifications yet
+ ) : ( + recentNotifs.map(notif => ( +
navigate('/notifications')} + > +

{notif.message}

+

{timeAgo(notif.createdAt)}

+
+ )) + )} +
+
+ +
+
+ )} +
+ {user?.qp || 0} QP @{user?.username} diff --git a/client/src/components/SkeletonLoader.jsx b/client/src/components/SkeletonLoader.jsx new file mode 100644 index 0000000..b794c03 --- /dev/null +++ b/client/src/components/SkeletonLoader.jsx @@ -0,0 +1,45 @@ +export function Spinner({ size = 'sm' }) { + const sizes = { xs: 'w-3 h-3', sm: 'w-4 h-4', md: 'w-5 h-5', lg: 'w-6 h-6' }; + return ( + + + + + ); +} + +export function SkeletonLine({ className = '' }) { + return
; +} + +export function SkeletonCard() { + return ( +
+
+
+ + +
+ +
+
+ ); +} + +export function SkeletonRow() { + return ( +
+
+
+ + +
+ +
+ ); +} \ No newline at end of file diff --git a/client/src/components/Toast.jsx b/client/src/components/Toast.jsx new file mode 100644 index 0000000..ea3625d --- /dev/null +++ b/client/src/components/Toast.jsx @@ -0,0 +1,65 @@ +import { useState, useEffect, useCallback, createContext, useContext } from 'react'; + +const ToastContext = createContext(null); + +let toastId = 0; + +export function ToastProvider({ children }) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message, type = 'info', duration = 4000) => { + const id = ++toastId; + setToasts(prev => [...prev, { id, message, type }]); + if (duration > 0) { + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, duration); + } + return id; + }, []); + + const removeToast = useCallback((id) => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, []); + + return ( + + {children} +
+ {toasts.map(toast => ( +
+ {toast.type === 'success' && } + {toast.type === 'error' && } + {toast.type === 'warning' && ⚠️} + {toast.type === 'info' && 💬} + {toast.message} + +
+ ))} +
+
+ ); +} + +export function useToast() { + const ctx = useContext(ToastContext); + return ctx || { addToast: () => {} }; +} + +export function toast(message, type = 'info') { + const { addToast } = useToast(); + addToast(message, type); +} diff --git a/client/src/components/UpvoteButton.jsx b/client/src/components/UpvoteButton.jsx index 1e65122..d88fdb6 100644 --- a/client/src/components/UpvoteButton.jsx +++ b/client/src/components/UpvoteButton.jsx @@ -2,6 +2,15 @@ import { useState } from 'react'; import { ArrowBigUp } from 'lucide-react'; import { cn } from '../utils/helpers'; +function UpvoteSpinner() { + return ( + + + + + ); +} + export default function UpvoteButton({ upvotes, onUpvote, hasUpvoted }) { const [loading, setLoading] = useState(false); @@ -26,7 +35,7 @@ export default function UpvoteButton({ upvotes, onUpvote, hasUpvoted }) { : 'bg-surface text-muted border border-border hover:bg-slate-100 hover:text-primary' )} > - + {loading ? : } {upvotes || 0} ); diff --git a/client/src/pages/AddFAQPage.jsx b/client/src/pages/AddFAQPage.jsx index d5517c3..6205ae0 100644 --- a/client/src/pages/AddFAQPage.jsx +++ b/client/src/pages/AddFAQPage.jsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import faqService from '../services/faq.service'; import { useQP } from '../context/QPContext'; import { FAQ_CATEGORIES } from '../utils/constants'; +import Breadcrumb from '../components/Breadcrumb'; export default function AddFAQPage() { const [form, setForm] = useState({ question: '', answer: '', category: '', tags: '' }); @@ -38,6 +39,7 @@ export default function AddFAQPage() { return (
+

Add New FAQ

diff --git a/client/src/pages/FAQEditPage.jsx b/client/src/pages/FAQEditPage.jsx new file mode 100644 index 0000000..5d4cef0 --- /dev/null +++ b/client/src/pages/FAQEditPage.jsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import faqService from '../services/faq.service'; +import { useQP } from '../context/QPContext'; +import { FAQ_CATEGORIES } from '../utils/constants'; +import Breadcrumb from '../components/Breadcrumb'; +import { Spinner } from '../components/SkeletonLoader'; + +export default function FAQEditPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const { refreshQP } = useQP(); + const [form, setForm] = useState({ question: '', answer: '', category: '', tags: '', isTrending: false, markedForReview: false }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + faqService.get(id) + .then(faq => { + setForm({ + question: faq.question, + answer: faq.answer, + category: faq.category, + tags: (faq.tags || []).join(', '), + isTrending: faq.isTrending || false, + markedForReview: faq.markedForReview || false, + }); + }) + .catch(() => navigate('/faq')) + .finally(() => setLoading(false)); + }, [id]); + + const handleChange = e => { + const { name, value, type, checked } = e.target; + setForm(f => ({ ...f, [name]: type === 'checkbox' ? checked : value })); + }; + + const handleSubmit = async e => { + e.preventDefault(); + if (!form.question || !form.answer || !form.category) { + setError('Question, answer, and category are required'); + return; + } + setError(''); + setSaving(true); + try { + const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean); + await faqService.update(id, { ...form, tags }); + navigate('/faq'); + } catch (err) { + setError(err.message || 'Failed to update FAQ'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ +

Edit FAQ

+
+ + {error && ( +
+ {error} +
+ )} +
+ +