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 (
+
+
+
+
+
+ {(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}
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/chat/MentorshipSessionsSidebar.tsx b/src/components/chat/MentorshipSessionsSidebar.tsx
new file mode 100644
index 0000000..1143dd6
--- /dev/null
+++ b/src/components/chat/MentorshipSessionsSidebar.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+
+type Participant = {
+ id: string;
+ github_handle: string;
+ display_name: string | null;
+ level: number | null;
+};
+
+type MentorshipSessionSummary = {
+ id: number;
+ mentor: Participant | null;
+ mentee: Participant | null;
+ level: number;
+ started_at: string;
+ ended_at: string | null;
+};
+
+export default function MentorshipSessionsSidebar() {
+ const [sessions, setSessions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ async function fetchSessions() {
+ try {
+ const res = await fetch('/api/mentorship/sessions');
+ if (!res.ok) {
+ throw new Error('Unable to fetch mentorship sessions');
+ }
+ const data = await res.json();
+ setSessions(data.sessions ?? []);
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ }
+ fetchSessions();
+ }, []);
+
+ return (
+
+
+
+
Active mentorship
+
Live sessions and participant levels.
+
+
+ View all
+
+
+ {loading ? (
+ Loading sessions…
+ ) : error ? (
+ {error}
+ ) : sessions.length === 0 ? (
+ No active mentorship sessions right now.
+ ) : (
+
+ {sessions.map((session) => (
+
+
+
Session #{session.id}
+
+ L{session.level}
+
+
+
+ Mentor: {session.mentor?.display_name ?? session.mentor?.github_handle ?? 'Unknown'}
+
+
+ Mentee: {session.mentee?.display_name ?? session.mentee?.github_handle ?? 'Unknown'}
+
+
+ Started {new Date(session.started_at).toLocaleString()}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/chat/StartMentorshipChatButton.tsx b/src/components/chat/StartMentorshipChatButton.tsx
new file mode 100644
index 0000000..58cacbc
--- /dev/null
+++ b/src/components/chat/StartMentorshipChatButton.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+
+export default function StartMentorshipChatButton({ mentorHandle }: { mentorHandle: string }) {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const startChat = async () => {
+ setError(null);
+ setLoading(true);
+ try {
+ const res = await fetch('/api/mentorship/create', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mentorHandle }),
+ });
+ if (!res.ok) {
+ const payload = await res.json().catch(() => null);
+ throw new Error(payload?.error || 'Unable to start mentorship chat');
+ }
+ const data = await res.json();
+ router.push(`/mentorship/${data.session.id}`);
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/src/inngest/functions/mentorship.ts b/src/inngest/functions/mentorship.ts
new file mode 100644
index 0000000..c61b828
--- /dev/null
+++ b/src/inngest/functions/mentorship.ts
@@ -0,0 +1,71 @@
+import { inngest } from '../client';
+import { getServiceSupabase } from '@/lib/supabase/service';
+
+export const mentorship = inngest.createFunction(
+ { id: 'mentorship' },
+ {
+ event: 'mentorship.*',
+ },
+ async ({ event }) => {
+ const sb = getServiceSupabase();
+ if (!sb) return;
+
+ if (event.name === 'mentorship.session.created') {
+ await sb.from('activity_log').insert({
+ user_id: event.data.mentorId,
+ kind: 'mentorship.session.created',
+ detail: event.data,
+ });
+ return;
+ }
+
+ if (event.name === 'mentorship.message.sent') {
+ const content = String(event.data.content || '').toLowerCase();
+ const flagged =
+ content.includes('inappropriate') || content.includes('hate') || content.includes('abuse');
+ await sb.from('activity_log').insert({
+ user_id: event.data.senderId,
+ kind: 'mentorship.message.sent',
+ detail: event.data,
+ });
+ if (flagged) {
+ await sb.from('activity_log').insert({
+ user_id: event.data.senderId,
+ kind: 'mentorship.message.flagged',
+ detail: {
+ sessionId: event.data.sessionId,
+ messageId: event.data.messageId,
+ reason: 'possible inappropriate content',
+ content: event.data.content,
+ },
+ });
+ }
+ return;
+ }
+
+ if (event.name === 'mentorship.session.ended') {
+ const { data: sessionMessages } = await sb
+ .from('messages')
+ .select('content, sender_id')
+ .eq('session_id', event.data.sessionId)
+ .order('timestamp', { ascending: true });
+
+ const summary = sessionMessages
+ ? sessionMessages
+ .slice(0, 5)
+ .map((message: any) => `${message.sender_id}: ${message.content}`)
+ .join(' | ')
+ : 'No messages recorded.';
+
+ await sb.from('activity_log').insert({
+ user_id: event.data.mentorId,
+ kind: 'mentorship.session.summary',
+ detail: {
+ sessionId: event.data.sessionId,
+ summary,
+ },
+ });
+ return;
+ }
+ },
+);
diff --git a/src/lib/chat/index.ts b/src/lib/chat/index.ts
new file mode 100644
index 0000000..6c16864
--- /dev/null
+++ b/src/lib/chat/index.ts
@@ -0,0 +1,129 @@
+import { getDb } from '@/lib/db/client';
+import { mentorshipSessions, messages } from '@/lib/db/schema';
+import { inngest } from '@/inngest/client';
+import { eq, isNull, or, and } from 'drizzle-orm';
+
+export type MentorshipSessionRow = {
+ id: number;
+ mentor_id: string;
+ mentee_id: string;
+ level: number;
+ started_at: string;
+ ended_at: string | null;
+};
+
+export type MentorshipMessageRow = {
+ id: number;
+ session_id: number;
+ sender_id: string;
+ content: string;
+ timestamp: string;
+ read_status: 'unread' | 'read';
+};
+
+export async function createMentorshipSession(args: {
+ mentorId: string;
+ menteeId: string;
+ level?: number;
+}) {
+ const db = getDb();
+ const [session] = await db
+ .insert(mentorshipSessions)
+ .values({
+ mentorId: args.mentorId,
+ menteeId: args.menteeId,
+ level: args.level ?? 1,
+ })
+ .returning();
+
+ if (!session) throw new Error('Failed to create mentorship session');
+
+ await inngest.send({
+ name: 'mentorship.session.created',
+ data: {
+ sessionId: session.id,
+ mentorId: args.mentorId,
+ menteeId: args.menteeId,
+ level: session.level,
+ },
+ });
+
+ return session;
+}
+
+export async function endMentorshipSession(sessionId: number) {
+ const db = getDb();
+ const [session] = await db
+ .update(mentorshipSessions)
+ .set({ endedAt: new Date() })
+ .where(eq(mentorshipSessions.id, sessionId))
+ .returning();
+
+ if (!session) throw new Error('Failed to end mentorship session');
+
+ await inngest.send({
+ name: 'mentorship.session.ended',
+ data: {
+ sessionId: session.id,
+ mentorId: session.mentorId,
+ menteeId: session.menteeId,
+ endedAt: session.endedAt,
+ },
+ });
+
+ return session;
+}
+
+export async function appendMentorshipMessage(args: {
+ sessionId: number;
+ senderId: string;
+ content: string;
+}) {
+ const db = getDb();
+ const [message] = await db
+ .insert(messages)
+ .values({
+ sessionId: args.sessionId,
+ senderId: args.senderId,
+ content: args.content,
+ readStatus: 'unread',
+ })
+ .returning();
+
+ if (!message) throw new Error('Failed to create message');
+
+ await inngest.send({
+ name: 'mentorship.message.sent',
+ data: {
+ sessionId: message.sessionId,
+ senderId: message.senderId,
+ messageId: message.id,
+ content: message.content,
+ },
+ });
+
+ return message;
+}
+
+export async function getMentorshipSessionById(sessionId: number) {
+ const db = getDb();
+ const results = await db
+ .select()
+ .from(mentorshipSessions)
+ .where(eq(mentorshipSessions.id, sessionId))
+ .limit(1);
+ return results[0] ?? null;
+}
+
+export async function listActiveMentorshipSessionsForUser(userId: string) {
+ const db = getDb();
+ return db
+ .select()
+ .from(mentorshipSessions)
+ .where(
+ and(
+ isNull(mentorshipSessions.endedAt),
+ or(eq(mentorshipSessions.mentorId, userId), eq(mentorshipSessions.menteeId, userId)),
+ ),
+ );
+}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index b84646b..f67815d 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -256,6 +256,48 @@ export const helpRequests = pgTable(
}),
);
+export const mentorshipSessions = pgTable(
+ 'mentorship_sessions',
+ {
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
+ mentorId: uuid('mentor_id')
+ .notNull()
+ .references(() => profiles.id, { onDelete: 'cascade' }),
+ menteeId: uuid('mentee_id')
+ .notNull()
+ .references(() => profiles.id, { onDelete: 'cascade' }),
+ level: integer('level').notNull().default(1),
+ startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
+ endedAt: timestamp('ended_at', { withTimezone: true }),
+ },
+ (t) => ({
+ mentorIdx: index('mentorship_sessions_mentor_idx').on(t.mentorId),
+ menteeIdx: index('mentorship_sessions_mentee_idx').on(t.menteeId),
+ }),
+);
+
+export const messages = pgTable(
+ 'messages',
+ {
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
+ sessionId: bigint('session_id', { mode: 'number' })
+ .notNull()
+ .references(() => mentorshipSessions.id, { onDelete: 'cascade' }),
+ senderId: uuid('sender_id')
+ .notNull()
+ .references(() => profiles.id, { onDelete: 'cascade' }),
+ content: text('content').notNull(),
+ timestamp: timestamp('timestamp', { withTimezone: true }).notNull().defaultNow(),
+ readStatus: text('read_status', { enum: ['unread', 'read'] })
+ .notNull()
+ .default('unread'),
+ },
+ (t) => ({
+ sessionIdx: index('messages_session_idx').on(t.sessionId),
+ senderIdx: index('messages_sender_idx').on(t.senderId),
+ }),
+);
+
// ---------- cohorts + tags ----------
export const cohorts = pgTable('cohorts', {
diff --git a/supabase/migrations/0012_add_mentorship_sessions_and_messages.sql b/supabase/migrations/0012_add_mentorship_sessions_and_messages.sql
new file mode 100644
index 0000000..e2609fa
--- /dev/null
+++ b/supabase/migrations/0012_add_mentorship_sessions_and_messages.sql
@@ -0,0 +1,25 @@
+-- Add mentorship session and message logging tables.
+
+create table if not exists mentorship_sessions (
+ id bigserial primary key,
+ mentor_id uuid not null references profiles(id) on delete cascade,
+ mentee_id uuid not null references profiles(id) on delete cascade,
+ level integer not null default 1,
+ started_at timestamptz not null default now(),
+ ended_at timestamptz
+);
+
+create index if not exists mentorship_sessions_mentor_id_idx on mentorship_sessions(mentor_id);
+create index if not exists mentorship_sessions_mentee_id_idx on mentorship_sessions(mentee_id);
+
+create table if not exists messages (
+ id bigserial primary key,
+ session_id bigint not null references mentorship_sessions(id) on delete cascade,
+ sender_id uuid not null references profiles(id) on delete cascade,
+ content text not null,
+ timestamp timestamptz not null default now(),
+ read_status text not null default 'unread' check (read_status in ('unread', 'read'))
+);
+
+create index if not exists messages_session_id_idx on messages(session_id);
+create index if not exists messages_sender_id_idx on messages(sender_id);
diff --git a/tests/chat/__fixtures__/supabase-realtime.ts b/tests/chat/__fixtures__/supabase-realtime.ts
new file mode 100644
index 0000000..2d590d5
--- /dev/null
+++ b/tests/chat/__fixtures__/supabase-realtime.ts
@@ -0,0 +1,13 @@
+import { vi } from 'vitest';
+
+export const mockSupabaseRealtimeChannel = {
+ on: vi.fn().mockReturnThis(),
+ subscribe: vi.fn().mockResolvedValue({}),
+ unsubscribe: vi.fn().mockResolvedValue({}),
+ track: vi.fn().mockResolvedValue({}),
+ presenceState: vi.fn().mockReturnValue({}),
+};
+
+export const mockBrowserSupabase = {
+ channel: vi.fn(() => mockSupabaseRealtimeChannel),
+};
diff --git a/tests/chat/chat.test.ts b/tests/chat/chat.test.ts
new file mode 100644
index 0000000..3eb7c91
--- /dev/null
+++ b/tests/chat/chat.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+const mockSend = vi.fn();
+const mockInsert = vi.fn();
+
+vi.mock('@/lib/db/client', () => ({
+ getDb: () => ({
+ insert: mockInsert,
+ update: vi.fn().mockReturnThis(),
+ set: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ returning: vi.fn().mockResolvedValue([{ id: 1 }]),
+ }),
+}));
+
+vi.mock('@/inngest/client', () => ({
+ inngest: {
+ send: mockSend,
+ },
+}));
+
+const { createMentorshipSession, appendMentorshipMessage } = await import('@/lib/chat');
+
+describe('mentorship chat helpers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockInsert.mockReset();
+ mockInsert.mockImplementation(() => ({
+ values: () => ({
+ returning: vi
+ .fn()
+ .mockResolvedValue([{ id: 1, mentorId: 'mentor-id', menteeId: 'mentee-id', level: 1 }]),
+ }),
+ }));
+ });
+
+ it('creates a mentorship session and emits an event', async () => {
+ mockInsert.mockImplementationOnce(() => ({
+ values: () => ({
+ returning: vi
+ .fn()
+ .mockResolvedValue([{ id: 42, mentorId: 'mentor-id', menteeId: 'mentee-id', level: 1 }]),
+ }),
+ }));
+
+ const session = await createMentorshipSession({ mentorId: 'mentor-id', menteeId: 'mentee-id' });
+ expect(session.id).toBe(42);
+ expect(mockSend).toHaveBeenCalledWith({
+ name: 'mentorship.session.created',
+ data: expect.objectContaining({
+ sessionId: 42,
+ mentorId: 'mentor-id',
+ menteeId: 'mentee-id',
+ }),
+ });
+ });
+
+ it('appends a mentorship message and emits a message event', async () => {
+ mockInsert.mockImplementationOnce(() => ({
+ values: () => ({
+ returning: vi
+ .fn()
+ .mockResolvedValue([
+ {
+ id: 88,
+ sessionId: 42,
+ senderId: 'mentor-id',
+ content: 'Hello',
+ readStatus: 'unread',
+ },
+ ]),
+ }),
+ }));
+
+ const message = await appendMentorshipMessage({
+ sessionId: 42,
+ senderId: 'mentor-id',
+ content: 'Hello',
+ });
+ expect(message.id).toBe(88);
+ expect(mockSend).toHaveBeenCalledWith({
+ name: 'mentorship.message.sent',
+ data: expect.objectContaining({
+ sessionId: 42,
+ senderId: 'mentor-id',
+ messageId: 88,
+ content: 'Hello',
+ }),
+ });
+ });
+});