From f9a3faeddc354e0f3d3e381004a2d42c01d1174e Mon Sep 17 00:00:00 2001 From: Sanika Jagtap Date: Thu, 21 May 2026 21:33:13 +0530 Subject: [PATCH] Add mentorship chat feature --- package-lock.json | 24 -- scripts/diagnose-tsx.js | 15 ++ src/app/(app)/dashboard/page.tsx | 5 + src/app/(app)/maintainer/mentorship/page.tsx | 105 ++++++++ src/app/(app)/maintainer/page.tsx | 6 + src/app/[handle]/page.tsx | 2 + src/app/api/inngest/route.ts | 2 + src/app/api/mentorship/create/route.ts | 51 ++++ src/app/api/mentorship/logs/route.ts | 116 +++++++++ src/app/api/mentorship/message/route.ts | 33 +++ src/app/api/mentorship/messages/route.ts | 41 +++ src/app/api/mentorship/sessions/route.ts | 38 +++ src/app/mentorship/[sessionId]/page.tsx | 66 +++++ src/components/chat/ChatPanel.tsx | 235 ++++++++++++++++++ .../chat/MentorshipSessionsSidebar.tsx | 91 +++++++ .../chat/StartMentorshipChatButton.tsx | 46 ++++ src/inngest/functions/mentorship.ts | 71 ++++++ src/lib/chat/index.ts | 129 ++++++++++ src/lib/db/schema.ts | 42 ++++ ...2_add_mentorship_sessions_and_messages.sql | 25 ++ tests/chat/__fixtures__/supabase-realtime.ts | 13 + tests/chat/chat.test.ts | 91 +++++++ 22 files changed, 1223 insertions(+), 24 deletions(-) create mode 100644 scripts/diagnose-tsx.js create mode 100644 src/app/(app)/maintainer/mentorship/page.tsx create mode 100644 src/app/api/mentorship/create/route.ts create mode 100644 src/app/api/mentorship/logs/route.ts create mode 100644 src/app/api/mentorship/message/route.ts create mode 100644 src/app/api/mentorship/messages/route.ts create mode 100644 src/app/api/mentorship/sessions/route.ts create mode 100644 src/app/mentorship/[sessionId]/page.tsx create mode 100644 src/components/chat/ChatPanel.tsx create mode 100644 src/components/chat/MentorshipSessionsSidebar.tsx create mode 100644 src/components/chat/StartMentorshipChatButton.tsx create mode 100644 src/inngest/functions/mentorship.ts create mode 100644 src/lib/chat/index.ts create mode 100644 supabase/migrations/0012_add_mentorship_sessions_and_messages.sql create mode 100644 tests/chat/__fixtures__/supabase-realtime.ts create mode 100644 tests/chat/chat.test.ts diff --git a/package-lock.json b/package-lock.json index 881e9c3..e9eb891 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1694,7 +1694,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -1923,7 +1922,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2036,7 +2034,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -4236,7 +4233,6 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.4.tgz", "integrity": "sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ==", "license": "MIT", - "peer": true, "dependencies": { "@supabase/auth-js": "2.105.4", "@supabase/functions-js": "2.105.4", @@ -4572,7 +4568,6 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -4601,7 +4596,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4613,7 +4607,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -5315,7 +5308,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5845,7 +5837,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7090,7 +7081,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7165,7 +7155,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7334,7 +7323,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9338,7 +9326,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10117,7 +10104,6 @@ "integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==", "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "14.2.5", "@swc/helpers": "0.5.5", @@ -10745,7 +10731,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10916,7 +10901,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -10980,7 +10964,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11149,7 +11132,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11162,7 +11144,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12692,7 +12673,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12803,7 +12783,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -13360,7 +13339,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13585,7 +13563,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -14099,7 +14076,6 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", 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 e3be321..012595a 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -4,6 +4,7 @@ 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'; @@ -231,6 +232,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 4e5987c..e4f90ba 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -154,6 +154,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 5caeb7b..bc8a755 100644 --- a/src/app/api/inngest/route.ts +++ b/src/app/api/inngest/route.ts @@ -21,6 +21,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, @@ -44,5 +45,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}
+ )} + +
+