diff --git a/scripts/diagnose-tsx.js b/scripts/diagnose-tsx.js new file mode 100644 index 0000000..bd41a8d --- /dev/null +++ b/scripts/diagnose-tsx.js @@ -0,0 +1,15 @@ +const fs = require('fs'); +const ts = require('typescript'); +const source = fs.readFileSync('src/app/(app)/maintainer/mentorship/page.tsx', 'utf8'); +const file = ts.createSourceFile( + 'page.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const diagnostics = file.parseDiagnostics.map((d) => ({ + line: d.start ? file.getLineAndCharacterOfPosition(d.start).line + 1 : null, + message: d.messageText, +})); +console.log(JSON.stringify(diagnostics, null, 2)); diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 73aea65..9f5ff6c 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -2,6 +2,9 @@ import { Suspense } from 'react'; import { getServerSupabase } from '@/lib/supabase/server'; import { getServiceSupabase } from '@/lib/supabase/service'; import { SyncButton } from './sync-button'; +import { GitHubPRsPanel } from './github-prs-panel'; +import MentorshipSessionsSidebar from '@/components/chat/MentorshipSessionsSidebar'; +import RecCards from './rec-cards'; import LevelUpBanner from './level-up-banner'; import { redirect } from 'next/navigation'; import Link from 'next/link'; @@ -60,6 +63,10 @@ export default async function DashboardPage() { +
+ +
+ {/* Main Columns */}
{/* Left Column */} diff --git a/src/app/(app)/maintainer/mentorship/page.tsx b/src/app/(app)/maintainer/mentorship/page.tsx new file mode 100644 index 0000000..b422cf1 --- /dev/null +++ b/src/app/(app)/maintainer/mentorship/page.tsx @@ -0,0 +1,105 @@ +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { isUserMaintainer } from '@/lib/maintainer/detect'; +import { redirect } from 'next/navigation'; + +export const dynamic = 'force-dynamic'; + +type ProfileLink = { github_handle: string | null; display_name: string | null }; + +type SessionRow = { + id: number; + level: number; + started_at: string; + ended_at: string | null; + mentor: ProfileLink[] | null; + mentee: ProfileLink[] | null; +}; + +export default async function MaintainerMentorshipPage() { + const supabase = getServerSupabase(); + if (!supabase) { + redirect('/dashboard'); + } + + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/dashboard'); + if (!(await isUserMaintainer(user.id))) redirect('/dashboard'); + + const service = getServiceSupabase(); + if (!service) { + return
Supabase service client not configured.
; + } + + const { data: sessions } = await service + .from('mentorship_sessions') + .select( + 'id, level, started_at, ended_at, mentor:profiles(github_handle, display_name), mentee:profiles(github_handle, display_name)', + ) + .order('started_at', { ascending: false }); + + return ( +
+
+
+

Mentorship session logs

+

+ Review past mentorship sessions, audit transcripts, and export logs as JSON or CSV. +

+ +
+ +
+ {(sessions ?? []).map((session: SessionRow) => { + const mentor = session.mentor?.[0]; + const mentee = session.mentee?.[0]; + + return ( +
+
+
+

Session #{session.id}

+

+ Mentor: {mentor?.display_name ?? mentor?.github_handle ?? 'Unknown'} · Mentee:{' '} + {mentee?.display_name ?? mentee?.github_handle ?? 'Unknown'} +

+
+ + Level {session.level} + +
+
+ Started {new Date(session.started_at).toLocaleString()} · Ended{' '} + {session.ended_at ? new Date(session.ended_at).toLocaleString() : 'active'} +
+
+ ); + })} + {(sessions ?? []).length === 0 && ( +
+ No mentorship sessions recorded yet. +
+ )} +
+
+
+ ); +} diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index 9496e2d..7d5a99a 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -158,6 +158,12 @@ export default async function MaintainerPage({ > Community links → + + Mentorship logs → +

diff --git a/src/app/[handle]/page.tsx b/src/app/[handle]/page.tsx index 24705ad..6501140 100644 --- a/src/app/[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -4,6 +4,7 @@ import { cacheGet, cacheSet } from '@/lib/cache'; import Link from 'next/link'; import { ExternalLink, ArrowLeft } from 'lucide-react'; import { CopyButton } from '@/components/copy-button'; +import StartMentorshipChatButton from '@/components/chat/StartMentorshipChatButton'; export const revalidate = 300; @@ -383,6 +384,7 @@ export default async function PublicProfile({ params }: { params: { handle: stri textToCopy={`${process.env.NEXT_PUBLIC_APP_URL ?? 'https://mergeship.dev'}/@${profile.githubHandle}`} />

+
{profile.prsMerged} PRS MERGED diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts index 29260f6..37a7d0b 100644 --- a/src/app/api/inngest/route.ts +++ b/src/app/api/inngest/route.ts @@ -26,6 +26,7 @@ import { githubStatsSync } from '@/inngest/functions/github-stats-sync'; import { mentorPostComment } from '@/inngest/functions/mentor-post-comment'; import { processIssueEvent } from '@/inngest/functions/process-issue-event'; import { processIssueCommentEvent } from '@/inngest/functions/process-issue-comment-event'; +import { mentorship } from '@/inngest/functions/mentorship'; export const { GET, POST, PUT } = serve({ client: inngest, @@ -50,5 +51,6 @@ export const { GET, POST, PUT } = serve({ mentorPostComment, processIssueEvent, processIssueCommentEvent, + mentorship, ], }); diff --git a/src/app/api/mentorship/create/route.ts b/src/app/api/mentorship/create/route.ts new file mode 100644 index 0000000..e05158f --- /dev/null +++ b/src/app/api/mentorship/create/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { createMentorshipSession } from '@/lib/chat'; + +export async function POST(req: Request) { + const supabase = getServerSupabase(); + if (!supabase) { + return NextResponse.json({ error: 'Supabase not configured' }, { status: 500 }); + } + + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'not_authenticated' }, { status: 401 }); + } + + const body = await req.json().catch(() => null); + const mentorHandle = body?.mentorHandle; + if (!mentorHandle || typeof mentorHandle !== 'string') { + return NextResponse.json({ error: 'mentorHandle is required' }, { status: 400 }); + } + + const service = getServiceSupabase(); + if (!service) { + return NextResponse.json({ error: 'Supabase service client not configured' }, { status: 500 }); + } + + const { data: mentorProfile, error: mentorError } = await service + .from('profiles') + .select('id') + .eq('github_handle', mentorHandle) + .maybeSingle(); + + if (mentorError || !mentorProfile) { + return NextResponse.json({ error: 'mentor_not_found' }, { status: 404 }); + } + + if (mentorProfile.id === user.id) { + return NextResponse.json({ error: 'cannot_start_chat_with_self' }, { status: 400 }); + } + + const session = await createMentorshipSession({ + mentorId: mentorProfile.id, + menteeId: user.id, + level: 1, + }); + + return NextResponse.json({ session }); +} diff --git a/src/app/api/mentorship/logs/route.ts b/src/app/api/mentorship/logs/route.ts new file mode 100644 index 0000000..dafd3f0 --- /dev/null +++ b/src/app/api/mentorship/logs/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { isUserMaintainer } from '@/lib/maintainer/detect'; + +function csvEscape(value: string | null | undefined) { + const text = value ?? ''; + return `"${text.replace(/"/g, '""')}"`; +} + +export async function GET(req: NextRequest) { + const supabase = getServerSupabase(); + if (!supabase) { + return NextResponse.json({ error: 'Supabase not configured' }, { status: 500 }); + } + + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'not_authenticated' }, { status: 401 }); + } + + if (!(await isUserMaintainer(user.id))) { + return NextResponse.json({ error: 'not_authorised' }, { status: 403 }); + } + + const service = getServiceSupabase(); + if (!service) { + return NextResponse.json({ error: 'Supabase service client not configured' }, { status: 500 }); + } + + const format = req.nextUrl.searchParams.get('format') ?? 'json'; + const { data: sessions, error: sessionsError } = await service + .from('mentorship_sessions') + .select( + 'id, mentor_id, mentee_id, level, started_at, ended_at, mentor:profiles(id, github_handle, display_name), mentee:profiles(id, github_handle, display_name)', + ) + .order('started_at', { ascending: false }); + + if (sessionsError) { + return NextResponse.json({ error: sessionsError.message }, { status: 500 }); + } + + const sessionIds = (sessions ?? []).map((session) => session.id); + const { data: messages, error: messagesError } = await service + .from('messages') + .select( + 'id, session_id, sender_id, content, timestamp, read_status, sender:profiles(id, github_handle)', + ) + .in('session_id', sessionIds) + .order('timestamp', { ascending: true }); + + if (messagesError) { + return NextResponse.json({ error: messagesError.message }, { status: 500 }); + } + + const payload = { + sessions: sessions ?? [], + messages: messages ?? [], + }; + + if (format === 'csv') { + const rows = [ + 'session_id,mentor_handle,mentee_handle,level,started_at,ended_at,message_id,sender_handle,message,timestamp,read_status', + ]; + for (const session of payload.sessions) { + const sessionMessages = payload.messages.filter( + (message) => message.session_id === session.id, + ); + if (sessionMessages.length === 0) { + rows.push( + [ + session.id, + session.mentor?.[0]?.github_handle ?? '', + session.mentee?.[0]?.github_handle ?? '', + session.level, + session.started_at, + session.ended_at ?? '', + '', + '', + '', + '', + '', + ].join(','), + ); + } + for (const message of sessionMessages) { + rows.push( + [ + session.id, + session.mentor?.[0]?.github_handle ?? '', + session.mentee?.[0]?.github_handle ?? '', + session.level, + session.started_at, + session.ended_at ?? '', + message.id, + message.sender?.[0]?.github_handle ?? '', + csvEscape(message.content), + message.timestamp, + message.read_status, + ].join(','), + ); + } + } + + return new Response(rows.join('\n'), { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="mentorship-logs.csv"', + }, + }); + } + + return NextResponse.json(payload); +} diff --git a/src/app/api/mentorship/message/route.ts b/src/app/api/mentorship/message/route.ts new file mode 100644 index 0000000..4ead3a8 --- /dev/null +++ b/src/app/api/mentorship/message/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { appendMentorshipMessage } from '@/lib/chat'; + +export async function POST(req: Request) { + const supabase = getServerSupabase(); + if (!supabase) { + return NextResponse.json({ error: 'Supabase not configured' }, { status: 500 }); + } + + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'not_authenticated' }, { status: 401 }); + } + + const body = await req.json().catch(() => null); + const sessionId = Number(body?.sessionId); + const content = body?.content; + + if (!sessionId || !content || typeof content !== 'string') { + return NextResponse.json({ error: 'sessionId and content are required' }, { status: 400 }); + } + + const message = await appendMentorshipMessage({ + sessionId, + senderId: user.id, + content: content.trim(), + }); + + return NextResponse.json({ message }); +} diff --git a/src/app/api/mentorship/messages/route.ts b/src/app/api/mentorship/messages/route.ts new file mode 100644 index 0000000..da1e7d3 --- /dev/null +++ b/src/app/api/mentorship/messages/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; + +export async function GET(req: NextRequest) { + const supabase = getServerSupabase(); + if (!supabase) { + return NextResponse.json({ error: 'Supabase not configured' }, { status: 500 }); + } + + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'not_authenticated' }, { status: 401 }); + } + + const sessionId = req.nextUrl.searchParams.get('sessionId'); + if (!sessionId) { + return NextResponse.json({ error: 'sessionId is required' }, { status: 400 }); + } + + const service = getServiceSupabase(); + if (!service) { + return NextResponse.json({ error: 'Supabase service client not configured' }, { status: 500 }); + } + + const { data: messages, error } = await service + .from('messages') + .select( + 'id, session_id, sender_id, content, timestamp, read_status, sender:profiles(id, github_handle, display_name)', + ) + .eq('session_id', Number(sessionId)) + .order('timestamp', { ascending: true }); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(messages ?? []); +} diff --git a/src/app/api/mentorship/sessions/route.ts b/src/app/api/mentorship/sessions/route.ts new file mode 100644 index 0000000..aa38afb --- /dev/null +++ b/src/app/api/mentorship/sessions/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; + +export async function GET(req: Request) { + const supabase = getServerSupabase(); + if (!supabase) { + return NextResponse.json({ error: 'Supabase not configured' }, { status: 500 }); + } + + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'not_authenticated' }, { status: 401 }); + } + + const service = getServiceSupabase(); + if (!service) { + return NextResponse.json({ error: 'Supabase service client not configured' }, { status: 500 }); + } + + const filter = `mentor_id.eq.${user.id},mentee_id.eq.${user.id}`; + const { data: sessions, error } = await service + .from('mentorship_sessions') + .select( + 'id, level, started_at, ended_at, mentor:profiles(id, github_handle, display_name, level), mentee:profiles(id, github_handle, display_name, level)', + ) + .or(filter) + .eq('ended_at', null) + .order('started_at', { ascending: false }); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ sessions: sessions ?? [] }); +} diff --git a/src/app/mentorship/[sessionId]/page.tsx b/src/app/mentorship/[sessionId]/page.tsx new file mode 100644 index 0000000..47f5611 --- /dev/null +++ b/src/app/mentorship/[sessionId]/page.tsx @@ -0,0 +1,66 @@ +import { getServerSupabase } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import ChatPanel from '@/components/chat/ChatPanel'; +import MentorshipSessionsSidebar from '@/components/chat/MentorshipSessionsSidebar'; + +export const dynamic = 'force-dynamic'; + +export default async function MentorshipSessionPage({ params }: { params: { sessionId: string } }) { + const supabase = getServerSupabase(); + if (!supabase) { + redirect('/dashboard'); + } + + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + redirect('/'); + } + + const { data: sessionData } = await supabase + .from('mentorship_sessions') + .select( + 'id, mentor_id, mentee_id, level, mentor:profiles(id, github_handle), mentee:profiles(id, github_handle)', + ) + .eq('id', Number(params.sessionId)) + .maybeSingle(); + + if (!sessionData) { + redirect('/dashboard'); + } + + const mentorHandle = sessionData.mentor?.[0]?.github_handle ?? 'mentor'; + const menteeHandle = sessionData.mentee?.[0]?.github_handle ?? 'mentee'; + + return ( +
+
+
+
+
+

Mentorship session

+

Session #{params.sessionId}

+
+
+ Level {sessionData.level} +
+
+

+ Live mentorship chat with logs stored for transparency and maintainers. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx new file mode 100644 index 0000000..376da90 --- /dev/null +++ b/src/components/chat/ChatPanel.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getBrowserSupabase, isSupabaseConfigured } from '@/lib/supabase/browser'; + +type ChatMessage = { + id: number; + session_id: number; + sender_id: string; + content: string; + timestamp: string; + read_status: 'unread' | 'read'; + sender?: { + github_handle: string; + display_name: string | null; + }; +}; + +type ChatPanelProps = { + sessionId: string; + currentUserId: string; + mentorHandle: string; + menteeHandle: string; +}; + +export default function ChatPanel({ + sessionId, + currentUserId, + mentorHandle, + menteeHandle, +}: ChatPanelProps) { + const [messages, setMessages] = useState([]); + const [messageText, setMessageText] = useState(''); + const [typingUsers, setTypingUsers] = useState([]); + const [realtimeEnabled, setRealtimeEnabled] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const socketRef = useRef(null); + const isSupabase = isSupabaseConfigured(); + + const displayName = useMemo(() => { + if (currentUserId) { + return currentUserId; + } + return 'you'; + }, [currentUserId]); + + const loadMessages = useCallback(async () => { + try { + const res = await fetch( + `/api/mentorship/messages?sessionId=${encodeURIComponent(sessionId)}`, + ); + if (!res.ok) { + throw new Error('Failed to load chat history'); + } + setMessages(await res.json()); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [sessionId]); + + useEffect(() => { + loadMessages(); + }, [loadMessages]); + + useEffect(() => { + let channel: any; + let ws: WebSocket | null = null; + + if (isSupabase) { + const supabase = getBrowserSupabase(); + if (supabase) { + channel = supabase.channel(`mentorship:${sessionId}`); + + channel.on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'messages', + filter: `session_id=eq.${sessionId}`, + }, + (payload: any) => { + setMessages((current) => [...current, payload.new]); + }, + ); + + channel.on('presence', { event: 'sync' }, () => { + const state = (channel as any).presenceState?.() ?? {}; + const people = Object.values(state) + .flatMap((members: any) => + members.map((member: any) => member.user_id ?? member.user.user_id), + ) + .filter(Boolean) as string[]; + setTypingUsers(people); + }); + + channel.subscribe().then(() => setRealtimeEnabled(true)); + } + } else if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_CHAT_WS_URL) { + const url = new URL(process.env.NEXT_PUBLIC_CHAT_WS_URL, window.location.href); + url.searchParams.set('sessionId', sessionId); + ws = new WebSocket(url.toString()); + socketRef.current = ws; + + ws.addEventListener('message', (event) => { + try { + const payload = JSON.parse(event.data); + if (payload.type === 'message') { + setMessages((current) => [...current, payload.message]); + } + if (payload.type === 'presence') { + setTypingUsers(payload.users ?? []); + } + } catch { + // ignore malformed socket payloads + } + }); + ws.addEventListener('open', () => setRealtimeEnabled(true)); + } + + return () => { + if (channel) { + channel.unsubscribe(); + } + if (ws) { + ws.close(); + } + }; + }, [isSupabase, sessionId]); + + const sendMessage = async () => { + if (!messageText.trim()) return; + setError(null); + + try { + const res = await fetch('/api/mentorship/message', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: Number(sessionId), content: messageText.trim() }), + }); + if (!res.ok) { + throw new Error('Unable to send message'); + } + setMessageText(''); + } catch (err) { + setError((err as Error).message); + } + }; + + const typingLabel = typingUsers.length + ? `${typingUsers.filter((id) => id !== currentUserId).join(', ')} is typing...` + : ''; + + return ( +
+
+
+

Mentorship chat

+

+ {mentorHandle} ↔ {menteeHandle} +

+

+ {realtimeEnabled ? 'Realtime active' : 'Realtime fallback'} +

+
+ + {typingUsers.length ? 'Typing' : 'Live'} + +
+ +
+

Transparency notice

+

+ All mentorship chats are logged for transparency. Messages are recorded and available for + maintainer review. +

+
+ +
+ {loading &&

Loading chat history…

} + {!loading && messages.length === 0 && ( +

No messages yet. Start the conversation.

+ )} + {messages.map((message) => { + const isOwn = message.sender_id === currentUserId; + const senderLabel = + message.sender?.display_name || + message.sender?.github_handle || + (isOwn ? 'You' : 'Mentor'); + return ( +
+
+ {senderLabel} +
+

+ {message.content} +

+
+ {new Date(message.timestamp).toLocaleString()} +
+
+ ); + })} +
+ + {typingLabel &&
{typingLabel}
} + {error && ( +
{error}
+ )} + +
+