diff --git a/app/(protected)/InvalidSessionHandler.tsx b/app/(protected)/InvalidSessionHandler.tsx new file mode 100644 index 0000000..d0ccfe4 --- /dev/null +++ b/app/(protected)/InvalidSessionHandler.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useEffect } from 'react' +import { signOut } from 'next-auth/react' +import { useRouter } from 'next/navigation' + +export function InvalidSessionHandler() { + const router = useRouter() + + useEffect(() => { + // Clear the invalid session and redirect to login + signOut({ + redirect: false + }).then(() => { + router.push('/login?error=InvalidSession') + }) + }, [router]) + + return ( +
+
+

Session Invalid

+

+ Your session is no longer valid. Redirecting to login... +

+
+
+
+ ) +} \ No newline at end of file diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx index fc0fbe0..3a52fab 100644 --- a/app/(protected)/layout.tsx +++ b/app/(protected)/layout.tsx @@ -2,16 +2,32 @@ import { redirect } from "next/navigation" import { getCurrentUser } from "@/lib/auth" import { SnapDocsSidebar } from "@/components/layout/snapdocs-sidebar" import { ClientLayout } from "./ClientLayout" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth-config" +import { InvalidSessionHandler } from "./InvalidSessionHandler" interface ProtectedLayoutProps { children: React.ReactNode } export default async function ProtectedLayout({ children }: ProtectedLayoutProps) { + // First check if there's a valid session + const session = await getServerSession(authOptions) + + if (!session) { + // No valid session, redirect to login + redirect("/login") + } + + // Then get the full user data const user = await getCurrentUser() if (!user) { - redirect("/login") + // Session exists but user not found in database + // This means the session is invalid (user was deleted or session is corrupted) + console.error(`Session exists but user not found in database: ${session.user?.id}`) + // Return a client component that will clear the session + return } return ( diff --git a/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx b/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx index c373504..7e2c96e 100644 --- a/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx +++ b/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx @@ -61,9 +61,14 @@ interface PageEditorProps { name?: string | null email?: string | null } + workspaceMembers?: Array<{ + id: string + name?: string | null + email?: string | null + }> } -export default function PageEditorV2({ page, initialContent, user }: PageEditorProps) { +export default function PageEditorV2({ page, initialContent, user, workspaceMembers = [] }: PageEditorProps) { const router = useRouter() // const { isConnected, joinPage, leavePage } = useSocket() // Removed - using Yjs collaboration now const [title, setTitle] = useState(page.title || '') @@ -316,6 +321,7 @@ export default function PageEditorV2({ page, initialContent, user }: PageEditorP autoSaveInterval={2000} userId={user?.id} user={user} + workspaceMembers={workspaceMembers} /> diff --git a/app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx b/app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx index d31106a..9760d96 100644 --- a/app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx +++ b/app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx @@ -73,6 +73,22 @@ export default async function PageEditorPage({ params }: PageEditorPageProps) { notFound() } + // Get all workspace members for mentions + const workspaceMembers = await prisma.workspaceMember.findMany({ + where: { + workspaceId: resolvedParams.workspaceId + }, + include: { + user: { + select: { + id: true, + name: true, + email: true + } + } + } + }) + // Get page content from MongoDB const pageContent = await pageContentService.loadPageContent(resolvedParams.pageId) @@ -94,6 +110,7 @@ export default async function PageEditorPage({ params }: PageEditorPageProps) { page={transformedPage} initialContent={pageContent} user={user} + workspaceMembers={workspaceMembers.map(m => m.user)} /> ) } catch (error) { diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts new file mode 100644 index 0000000..ada1806 --- /dev/null +++ b/app/api/auth/verify/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth-config" + +export async function GET() { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json( + { error: "Unauthorized - Invalid or expired token" }, + { status: 403 } + ) + } + + return NextResponse.json({ + authenticated: true, + user: session.user + }) + } catch (error) { + console.error("Auth verification error:", error) + return NextResponse.json( + { error: "Authentication verification failed" }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/pages/[pageId]/content/route.ts b/app/api/pages/[pageId]/content/route.ts index 00bbbb0..466d54b 100644 --- a/app/api/pages/[pageId]/content/route.ts +++ b/app/api/pages/[pageId]/content/route.ts @@ -4,6 +4,7 @@ import { prisma } from '@/lib/db/prisma' import { pageContentService } from '@/lib/services/page-content' import { Block } from '@/types' import { ensureMentionsPopulated } from '@/lib/utils/mentions' +import { NotificationService } from '@/lib/services/notification' // GET /api/pages/[pageId]/content - Get page content export async function GET( @@ -136,6 +137,14 @@ export async function PUT( // Save content immediately (optimized for real-time collaboration) const savedContent = await pageContentService.savePageContent(pageId, blocks, user.id) + // Process mentions and create notifications + await NotificationService.processMentionsInContent( + blocks, + pageId, + page.workspaceId, + user.id + ) + // Update page metadata in PostgreSQL await prisma.page.update({ where: { id: pageId }, diff --git a/app/login/page.tsx b/app/login/page.tsx index 139eba1..8123811 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react" import { signIn, getSession, useSession } from "next-auth/react" -import { useRouter } from "next/navigation" +import { useRouter, useSearchParams } from "next/navigation" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -15,13 +15,20 @@ export default function LoginPage() { const [password, setPassword] = useState("") const [isLoading, setIsLoading] = useState(false) const router = useRouter() + const searchParams = useSearchParams() const { data: session, status } = useSession() useEffect(() => { + // Check for error messages in URL + const error = searchParams.get('error') + if (error === 'InvalidSession') { + toast.error('Your session has expired. Please login again.') + } + if (status === "authenticated") { router.push("/dashboard") } - }, [status, router]) + }, [status, router, searchParams]) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() diff --git a/components/editor/BlockNoteEditor.tsx b/components/editor/BlockNoteEditor.tsx index 2df37ab..b995a4c 100644 --- a/components/editor/BlockNoteEditor.tsx +++ b/components/editor/BlockNoteEditor.tsx @@ -6,6 +6,7 @@ import { Block as BlockNoteBlock, BlockNoteSchema, defaultBlockSpecs, + defaultInlineContentSpecs, filterSuggestionItems } from '@blocknote/core' import { @@ -29,6 +30,7 @@ import { cleanupCollaborationProvider } from '@/lib/collaboration/yjs-provider' import type YPartyKitProvider from 'y-partykit/provider' +import { Mention } from './Mention' // Dynamically import DatabaseBlock to avoid SSR issues const DatabaseBlock = dynamic(() => import('./DatabaseBlock'), { @@ -52,6 +54,11 @@ interface BlockNoteEditorProps { email?: string | null } enableCollaboration?: boolean + workspaceMembers?: Array<{ + id: string + name?: string | null + email?: string | null + }> } type SaveStatus = 'saved' | 'saving' | 'error' | 'unsaved' @@ -67,7 +74,8 @@ export default function BlockNoteEditorComponent({ showSaveStatus = true, userId, user, - enableCollaboration = true + enableCollaboration = true, + workspaceMembers = [] }: BlockNoteEditorProps) { const [saveStatus, setSaveStatus] = useState('saved') const autoSaveTimeoutRef = useRef(null) @@ -165,6 +173,10 @@ export default function BlockNoteEditorComponent({ const schema = useMemo(() => { return BlockNoteSchema.create({ blockSpecs: customBlockSpecs, + inlineContentSpecs: { + ...defaultInlineContentSpecs, + mention: Mention, + }, }) }, [customBlockSpecs]) @@ -275,6 +287,54 @@ export default function BlockNoteEditorComponent({ [insertDatabaseItem] ) + // Get mention menu items from workspace members + const getMentionMenuItems = useCallback( + (editor: any): DefaultReactSuggestionItem[] => { + // Include the current user + const allUsers = [ + ...(user ? [user] : []), + ...workspaceMembers.filter(member => member.id !== user?.id) + ] + + return allUsers.map((member) => ({ + title: member.name || member.email || 'Unknown User', + onItemClick: async () => { + editor.insertInlineContent([ + { + type: "mention", + props: { + user: member.name || member.email || 'Unknown', + userId: member.id, + email: member.email || undefined + }, + }, + " ", // add a space after the mention + ]) + + // Send notification for the mention + if (member.id !== user?.id) { + try { + await fetch('/api/notifications/mention', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: member.id, + pageId, + workspaceId, + message: `mentioned you in a document` + }) + }) + } catch (error) { + console.error('Failed to send mention notification:', error) + } + } + }, + subtext: member.email || undefined, + })) + }, + [user, workspaceMembers, pageId, workspaceId] + ) + // Convert BlockNote blocks back to our storage format const convertFromBlockNoteFormat = useCallback((blocks: BlockNoteBlock[]): AppBlockType[] => { try { @@ -441,6 +501,12 @@ export default function BlockNoteEditorComponent({ filterSuggestionItems(getCustomSlashMenuItems(editor), query) } /> + + filterSuggestionItems(getMentionMenuItems(editor as any), query) + } + /> diff --git a/components/editor/Mention.tsx b/components/editor/Mention.tsx new file mode 100644 index 0000000..a5b37b1 --- /dev/null +++ b/components/editor/Mention.tsx @@ -0,0 +1,48 @@ +'use client' + +import { createReactInlineContentSpec } from '@blocknote/react' +import { cn } from '@/lib/utils' + +export const Mention = createReactInlineContentSpec( + { + type: "mention" as const, + propSchema: { + user: { + default: "Unknown" as const + }, + userId: { + default: undefined, + type: "string" as const + }, + email: { + default: undefined, + type: "string" as const + } + } as const, + content: "none" as const, + }, + { + render: (props) => { + const { user, userId, email } = props.inlineContent.props + + return ( + + @{user} + + ) + } + } +) \ No newline at end of file diff --git a/components/layout/snapdocs-sidebar.tsx b/components/layout/snapdocs-sidebar.tsx index 6d1162e..dfaf7b5 100644 --- a/components/layout/snapdocs-sidebar.tsx +++ b/components/layout/snapdocs-sidebar.tsx @@ -24,7 +24,8 @@ import { Hash, Calendar, File, - Circle + Circle, + Bell } from "lucide-react" import { DropdownMenu, @@ -39,6 +40,7 @@ import { CreateWorkspaceModal } from "@/components/workspace/create-workspace-mo import { SearchDialog } from "@/components/search/SearchDialog" import { SettingsModal } from "@/components/settings/SettingsModal" import { AvatarUpload } from "@/components/ui/avatar-upload" +import { NotificationButton } from "@/components/notifications/NotificationPanel" import { cn } from "@/lib/utils" import toast from "react-hot-toast" @@ -308,6 +310,7 @@ export function SnapDocsSidebar({ user }: SnapDocsSidebarProps) { } onClick={() => setShowSearchDialog(true)} /> + } label="Settings & members" diff --git a/components/notifications/NotificationDropdown.tsx b/components/notifications/NotificationDropdown.tsx new file mode 100644 index 0000000..76d5dd4 --- /dev/null +++ b/components/notifications/NotificationDropdown.tsx @@ -0,0 +1,262 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { Bell, Check, CheckCheck, X } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { formatDistanceToNow } from 'date-fns' +import { useRouter } from 'next/navigation' +import toast from 'react-hot-toast' +import { cn } from '@/lib/utils' + +interface Notification { + id: string + type: string + title: string + message: string + read: boolean + createdAt: string + mentionedBy?: { + id: string + name: string | null + email: string + avatarUrl: string | null + } | null + page?: { + id: string + title: string + icon: string | null + } | null + workspace?: { + id: string + name: string + slug: string + } | null + metadata?: any +} + +export function NotificationDropdown() { + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const router = useRouter() + + // Fetch notifications + const fetchNotifications = async () => { + setIsLoading(true) + try { + const response = await fetch('/api/notifications') + if (response.ok) { + const data = await response.json() + setNotifications(data.notifications || []) + setUnreadCount(data.unreadCount || 0) + } + } catch (error) { + console.error('Error fetching notifications:', error) + } finally { + setIsLoading(false) + } + } + + // Mark notification as read + const markAsRead = async (notificationId: string) => { + try { + const response = await fetch('/api/notifications', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notificationIds: [notificationId] }) + }) + + if (response.ok) { + setNotifications(prev => + prev.map(n => n.id === notificationId ? { ...n, read: true } : n) + ) + setUnreadCount(prev => Math.max(0, prev - 1)) + } + } catch (error) { + console.error('Error marking notification as read:', error) + } + } + + // Mark all as read + const markAllAsRead = async () => { + try { + const response = await fetch('/api/notifications', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ markAllRead: true }) + }) + + if (response.ok) { + setNotifications(prev => prev.map(n => ({ ...n, read: true }))) + setUnreadCount(0) + toast.success('All notifications marked as read') + } + } catch (error) { + console.error('Error marking all as read:', error) + toast.error('Failed to mark notifications as read') + } + } + + // Handle notification click + const handleNotificationClick = async (notification: Notification) => { + // Mark as read if unread + if (!notification.read) { + await markAsRead(notification.id) + } + + // Navigate to the page if it exists + if (notification.page && notification.workspace) { + router.push(`/workspace/${notification.workspace.id}/page/${notification.page.id}`) + setIsOpen(false) + } + } + + // Fetch notifications when dropdown opens + useEffect(() => { + if (isOpen) { + fetchNotifications() + } + }, [isOpen]) + + // Initial fetch + useEffect(() => { + fetchNotifications() + }, []) + + // Poll for new notifications every 30 seconds + useEffect(() => { + const interval = setInterval(fetchNotifications, 30000) + return () => clearInterval(interval) + }, []) + + const getInitials = (name: string | null, email: string) => { + if (name) { + return name.split(' ').map(n => n[0]).join('').toUpperCase() + } + return email[0].toUpperCase() + } + + return ( + + + + + +
+ Notifications + {unreadCount > 0 && ( + + )} +
+ + + + {isLoading ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
+ No notifications yet +
+ ) : ( + notifications.map((notification) => ( + handleNotificationClick(notification)} + > + + + + {notification.mentionedBy + ? getInitials(notification.mentionedBy.name, notification.mentionedBy.email) + : '?'} + + + +
+
+
+

+ {notification.title} +

+

+ {notification.message} +

+
+ {!notification.read && ( +
+ )} +
+ +
+ {notification.page && ( + <> + {notification.page.icon && ( + {notification.page.icon} + )} + + {notification.page.title} + + + + )} + + {formatDistanceToNow(new Date(notification.createdAt), { + addSuffix: true + })} + +
+
+ + )) + )} + + + + ) +} \ No newline at end of file diff --git a/components/notifications/NotificationPanel.tsx b/components/notifications/NotificationPanel.tsx new file mode 100644 index 0000000..d05fff2 --- /dev/null +++ b/components/notifications/NotificationPanel.tsx @@ -0,0 +1,326 @@ +'use client' + +import React, { useState, useEffect, useRef } from 'react' +import { Bell, X, Check, CheckCheck } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { formatDistanceToNow } from 'date-fns' +import { useRouter } from 'next/navigation' +import toast from 'react-hot-toast' +import { cn } from '@/lib/utils' + +interface Notification { + id: string + type: string + title: string + message: string + read: boolean + createdAt: string + mentionedBy?: { + id: string + name: string | null + email: string + avatarUrl: string | null + } | null + page?: { + id: string + title: string + icon: string | null + } | null + workspace?: { + id: string + name: string + slug: string + } | null + metadata?: any +} + +interface NotificationPanelProps { + isOpen: boolean + onClose: () => void +} + +export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) { + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const router = useRouter() + + // Fetch notifications + const fetchNotifications = async () => { + setIsLoading(true) + try { + const response = await fetch('/api/notifications') + if (response.ok) { + const data = await response.json() + setNotifications(data.notifications || []) + setUnreadCount(data.unreadCount || 0) + } + } catch (error) { + console.error('Error fetching notifications:', error) + } finally { + setIsLoading(false) + } + } + + // Mark notification as read + const markAsRead = async (notificationId: string) => { + try { + const response = await fetch('/api/notifications', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notificationIds: [notificationId] }) + }) + + if (response.ok) { + setNotifications(prev => + prev.map(n => n.id === notificationId ? { ...n, read: true } : n) + ) + setUnreadCount(prev => Math.max(0, prev - 1)) + } + } catch (error) { + console.error('Error marking notification as read:', error) + } + } + + // Mark all as read + const markAllAsRead = async () => { + try { + const response = await fetch('/api/notifications', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ markAllRead: true }) + }) + + if (response.ok) { + setNotifications(prev => prev.map(n => ({ ...n, read: true }))) + setUnreadCount(0) + toast.success('All notifications marked as read') + } + } catch (error) { + console.error('Error marking all as read:', error) + toast.error('Failed to mark notifications as read') + } + } + + // Handle notification click + const handleNotificationClick = async (notification: Notification) => { + // Mark as read if unread + if (!notification.read) { + await markAsRead(notification.id) + } + + // Navigate to the page if it exists + if (notification.page && notification.workspace) { + router.push(`/workspace/${notification.workspace.id}/page/${notification.page.id}`) + onClose() + } + } + + // Fetch notifications when panel opens + useEffect(() => { + if (isOpen) { + fetchNotifications() + } + }, [isOpen]) + + // Initial fetch + useEffect(() => { + fetchNotifications() + }, []) + + // Poll for new notifications every 30 seconds + useEffect(() => { + const interval = setInterval(fetchNotifications, 30000) + return () => clearInterval(interval) + }, []) + + const getInitials = (name: string | null, email: string) => { + if (name) { + return name.split(' ').map(n => n[0]).join('').toUpperCase() + } + return email[0].toUpperCase() + } + + if (!isOpen) return null + + return ( + <> + {/* Backdrop - click outside to close */} +
+ + {/* Dropdown Panel positioned beside sidebar */} +
+ {/* Header */} +
+
+ +

Notifications

+ {unreadCount > 0 && ( + + {unreadCount} + + )} +
+
+ {unreadCount > 0 && ( + + )} + +
+
+ + {/* Notifications List */} + + {isLoading ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
+ No notifications yet +
+ ) : ( +
+ {notifications.map((notification) => ( +
handleNotificationClick(notification)} + > + + + + {notification.mentionedBy + ? getInitials(notification.mentionedBy.name, notification.mentionedBy.email) + : '?'} + + + +
+
+
+

+ {notification.title} +

+

+ {notification.message} +

+
+ {!notification.read && ( +
+ )} +
+ +
+ {notification.page && ( + <> + {notification.page.icon && ( + {notification.page.icon} + )} + + {notification.page.title} + + + + )} + + {formatDistanceToNow(new Date(notification.createdAt), { + addSuffix: true + })} + +
+
+
+ ))} +
+ )} + +
+ + ) +} + +export function NotificationButton() { + const [isOpen, setIsOpen] = useState(false) + const [unreadCount, setUnreadCount] = useState(0) + const buttonRef = useRef(null) + + // Fetch unread count + useEffect(() => { + const fetchUnreadCount = async () => { + try { + const response = await fetch('/api/notifications?unread=true') + if (response.ok) { + const data = await response.json() + setUnreadCount(data.unreadCount || 0) + } + } catch (error) { + console.error('Error fetching unread count:', error) + } + } + + fetchUnreadCount() + // Poll every 30 seconds + const interval = setInterval(fetchUnreadCount, 30000) + return () => clearInterval(interval) + }, []) + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + setIsOpen(false) + } + } + + if (isOpen) { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen]) + + return ( +
+ + setIsOpen(false)} /> +
+ ) +} \ No newline at end of file diff --git a/lib/api-auth.ts b/lib/api-auth.ts new file mode 100644 index 0000000..7a773d1 --- /dev/null +++ b/lib/api-auth.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth-config" +import { getCurrentUser } from "@/lib/auth" + +/** + * Validates authentication for API routes + * Returns 403 for invalid/expired tokens + * Returns 401 for missing authentication + */ +export async function validateApiAuth() { + try { + // First check if there's a session + const session = await getServerSession(authOptions) + + if (!session) { + return { + error: NextResponse.json( + { error: "Unauthorized - No valid session" }, + { status: 403 } + ), + user: null + } + } + + // Then get the full user data + const user = await getCurrentUser() + + if (!user) { + // Session exists but user not found + return { + error: NextResponse.json( + { error: "Forbidden - Invalid token or user not found" }, + { status: 403 } + ), + user: null + } + } + + return { + error: null, + user + } + } catch (error) { + console.error("API authentication error:", error) + return { + error: NextResponse.json( + { error: "Internal authentication error" }, + { status: 500 } + ), + user: null + } + } +} + +/** + * Wrapper for API route handlers that require authentication + */ +export function withApiAuth( + handler: (req: Request, context: any, user: any) => Promise +) { + return async (req: Request, context: any) => { + const { error, user } = await validateApiAuth() + + if (error) { + return error + } + + return handler(req, context, user) + } +} \ No newline at end of file diff --git a/lib/auth-config.ts b/lib/auth-config.ts index 66183ac..40245ad 100644 --- a/lib/auth-config.ts +++ b/lib/auth-config.ts @@ -72,15 +72,39 @@ export const authOptions: NextAuthOptions = { token.name = user.name token.avatarUrl = user.avatarUrl || null } + + // Validate that user still exists in database on every request + if (token && token.id) { + try { + const existingUser = await prisma.user.findUnique({ + where: { id: token.id as string }, + select: { id: true } + }) + + if (!existingUser) { + // User no longer exists, clear the token + console.error(`User ${token.id} no longer exists in database, clearing session`) + return {} as any // Return empty token to invalidate session + } + } catch (error) { + console.error('Error validating user in JWT callback:', error) + } + } + return token }, async session({ session, token }) { - if (token) { - session.user.id = token.id as string - session.user.email = token.email as string - session.user.name = token.name as string - session.user.avatarUrl = token.avatarUrl as string | null + // Check if token is valid (has required fields) + if (!token || !token.id || !token.email) { + // Invalid token, return null to invalidate session + return null as any } + + session.user.id = token.id as string + session.user.email = token.email as string + session.user.name = token.name as string + session.user.avatarUrl = token.avatarUrl as string | null + return session } }, diff --git a/lib/auth.ts b/lib/auth.ts index a38b5ce..cb9f5c8 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -3,8 +3,18 @@ import { authOptions } from "@/lib/auth-config" import { prisma } from "@/lib/db/prisma" export async function getServerSessionUser() { - const session = await getServerSession(authOptions) - return session?.user || null + try { + const session = await getServerSession(authOptions) + + if (!session || !session.user) { + return null + } + + return session.user + } catch (error) { + console.error("Error getting server session:", error) + return null + } } export async function getCurrentUser() { @@ -28,6 +38,13 @@ export async function getCurrentUser() { } }) + if (!user) { + // Session exists but user not found in database + // This could happen if user was deleted or session is invalid + console.error("Session user not found in database:", sessionUser.id) + return null + } + return user } catch (error) { console.error("Error fetching current user:", error) @@ -36,10 +53,16 @@ export async function getCurrentUser() { } export async function requireAuth() { + const session = await getServerSession(authOptions) + + if (!session) { + throw new Error("Authentication required - No valid session") + } + const user = await getCurrentUser() if (!user) { - throw new Error("Authentication required") + throw new Error("Authentication required - User not found") } return user diff --git a/lib/services/notification.ts b/lib/services/notification.ts new file mode 100644 index 0000000..0f2ebdb --- /dev/null +++ b/lib/services/notification.ts @@ -0,0 +1,337 @@ +import { prisma } from '@/lib/db/prisma' +import { NotificationType } from '@prisma/client' + +interface CreateMentionNotificationParams { + recipientId: string + mentionedById: string + pageId: string + workspaceId: string + blockId?: string + context?: string +} + +interface NotificationWithRelations { + id: string + type: NotificationType + title: string + message: string + read: boolean + createdAt: Date + updatedAt: Date + recipientId: string + mentionedById: string | null + mentionedBy: { + id: string + name: string | null + email: string + avatarUrl: string | null + } | null + page: { + id: string + title: string + icon: string | null + } | null + workspace: { + id: string + name: string + slug: string + } | null + metadata: any +} + +export class NotificationService { + /** + * Create a mention notification + */ + static async createMentionNotification({ + recipientId, + mentionedById, + pageId, + workspaceId, + blockId, + context + }: CreateMentionNotificationParams) { + try { + // Don't create notification if user mentions themselves + if (recipientId === mentionedById) { + return null + } + + // Get page and user details for the notification + const [page, mentionedByUser] = await Promise.all([ + prisma.page.findUnique({ + where: { id: pageId }, + select: { title: true } + }), + prisma.user.findUnique({ + where: { id: mentionedById }, + select: { name: true, email: true } + }) + ]) + + if (!page || !mentionedByUser) { + console.error('Page or user not found for mention notification') + return null + } + + const notification = await prisma.notification.create({ + data: { + type: NotificationType.MENTION, + title: `${mentionedByUser.name || mentionedByUser.email} mentioned you`, + message: `You were mentioned in "${page.title}"${context ? `: "${context}"` : ''}`, + recipientId, + mentionedById, + pageId, + workspaceId, + metadata: { + blockId, + context + } + }, + include: { + mentionedBy: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true + } + }, + page: { + select: { + id: true, + title: true, + icon: true + } + }, + workspace: { + select: { + id: true, + name: true, + slug: true + } + } + } + }) + + return notification + } catch (error) { + console.error('Error creating mention notification:', error) + return null + } + } + + /** + * Get notifications for a user + */ + static async getUserNotifications( + userId: string, + options?: { + unreadOnly?: boolean + limit?: number + offset?: number + } + ): Promise { + const { unreadOnly = false, limit = 20, offset = 0 } = options || {} + + const where: any = { + recipientId: userId + } + + if (unreadOnly) { + where.read = false + } + + const notifications = await prisma.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + include: { + mentionedBy: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true + } + }, + page: { + select: { + id: true, + title: true, + icon: true + } + }, + workspace: { + select: { + id: true, + name: true, + slug: true + } + } + } + }) + + return notifications + } + + /** + * Mark notification as read + */ + static async markAsRead(notificationId: string, userId: string) { + try { + const notification = await prisma.notification.update({ + where: { + id: notificationId, + recipientId: userId // Ensure user owns the notification + }, + data: { + read: true + } + }) + + return notification + } catch (error) { + console.error('Error marking notification as read:', error) + return null + } + } + + /** + * Mark all notifications as read for a user + */ + static async markAllAsRead(userId: string) { + try { + const result = await prisma.notification.updateMany({ + where: { + recipientId: userId, + read: false + }, + data: { + read: true + } + }) + + return result + } catch (error) { + console.error('Error marking all notifications as read:', error) + return null + } + } + + /** + * Get unread notification count + */ + static async getUnreadCount(userId: string): Promise { + try { + const count = await prisma.notification.count({ + where: { + recipientId: userId, + read: false + } + }) + + return count + } catch (error) { + console.error('Error getting unread notification count:', error) + return 0 + } + } + + /** + * Delete a notification + */ + static async deleteNotification(notificationId: string, userId: string) { + try { + const notification = await prisma.notification.delete({ + where: { + id: notificationId, + recipientId: userId // Ensure user owns the notification + } + }) + + return notification + } catch (error) { + console.error('Error deleting notification:', error) + return null + } + } + + /** + * Extract mentions from block content and create notifications + */ + static async processMentionsInContent( + content: any, + pageId: string, + workspaceId: string, + mentionedById: string + ) { + try { + // Extract all mentions from the content + const mentions = this.extractMentions(content) + + if (mentions.length === 0) { + return [] + } + + // Create notifications for each unique mention + const uniqueMentions = [...new Set(mentions.map(m => m.userId))] + const notifications = await Promise.all( + uniqueMentions.map(userId => + this.createMentionNotification({ + recipientId: userId, + mentionedById, + pageId, + workspaceId, + context: mentions.find(m => m.userId === userId)?.context + }) + ) + ) + + return notifications.filter(n => n !== null) + } catch (error) { + console.error('Error processing mentions in content:', error) + return [] + } + } + + /** + * Extract mentions from block content + * This needs to parse the BlockNote content structure + */ + private static extractMentions(content: any): Array<{ userId: string; context?: string }> { + const mentions: Array<{ userId: string; context?: string }> = [] + + // Helper function to recursively search for mentions + const searchForMentions = (obj: any, context: string = '') => { + if (!obj) return + + // Check if this is a mention inline content + if (obj.type === 'mention' && obj.props?.userId) { + mentions.push({ + userId: obj.props.userId, + context: context.slice(0, 100) // Limit context length + }) + } + + // Recursively search in arrays + if (Array.isArray(obj)) { + obj.forEach(item => searchForMentions(item, context)) + } + + // Recursively search in objects + else if (typeof obj === 'object') { + // Build context from text content + if (obj.type === 'text' && obj.text) { + context += obj.text + } + + Object.values(obj).forEach(value => searchForMentions(value, context)) + } + } + + searchForMentions(content) + return mentions + } +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..df6dd22 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,65 @@ +import { withAuth } from "next-auth/middleware" +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" + +export default withAuth( + function middleware(req) { + // This function is called if the token exists + // You can access the token via req.nextauth.token + return NextResponse.next() + }, + { + callbacks: { + authorized: ({ token, req }) => { + const pathname = req.nextUrl.pathname + + // Allow access to public routes + const publicRoutes = ['/login', '/signup', '/api/auth', '/', '/demo', '/forgot-password'] + const isPublicRoute = publicRoutes.some(route => pathname.startsWith(route)) + + if (isPublicRoute) { + return true + } + + // Allow access to static files and Next.js internals + if (pathname.startsWith('/_next') || pathname.startsWith('/api/_next')) { + return true + } + + // For API routes, check token and return false to trigger 403 + if (pathname.startsWith('/api/')) { + if (!token) { + // This will trigger a 403 response for API routes + return false + } + return true + } + + // For protected pages, check if token exists + if (!token) { + // For non-API routes, returning false will redirect to login + return false + } + + return true + }, + }, + pages: { + signIn: '/login', + error: '/login', + }, + } +) + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public folder + */ + '/((?!_next/static|_next/image|favicon.ico|public).*)', + ], +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61afd25..d0d7ad7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6383,8 +6383,8 @@ snapshots: '@typescript-eslint/parser': 8.40.0(eslint@8.57.1)(typescript@5.9.2) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -6403,7 +6403,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -6414,22 +6414,22 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.40.0(eslint@8.57.1)(typescript@5.9.2) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6440,7 +6440,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/scripts/check-user.ts b/scripts/check-user.ts new file mode 100644 index 0000000..b601581 --- /dev/null +++ b/scripts/check-user.ts @@ -0,0 +1,54 @@ +import { prisma } from '../lib/db/prisma' + +async function checkUser() { + const userId = 'cmeubd19x00006zcdy52aaf1n' + + console.log(`Checking for user with ID: ${userId}`) + + try { + const user = await prisma.user.findUnique({ + where: { id: userId } + }) + + if (user) { + console.log('User found:', { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt + }) + } else { + console.log('User NOT found in database') + console.log('\nThis user ID exists in your session but not in the database.') + console.log('You need to clear your browser cookies/session to fix this.') + console.log('\nTo clear session:') + console.log('1. Clear browser cookies for this site') + console.log('2. Or open Developer Tools > Application > Storage > Clear Site Data') + } + + // Also list all existing users + console.log('\n--- All existing users ---') + const allUsers = await prisma.user.findMany({ + select: { + id: true, + email: true, + name: true + } + }) + + if (allUsers.length === 0) { + console.log('No users found in database') + } else { + allUsers.forEach(u => { + console.log(`- ${u.email} (${u.id})`) + }) + } + + } catch (error) { + console.error('Error checking user:', error) + } finally { + await prisma.$disconnect() + } +} + +checkUser() \ No newline at end of file