Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions scripts/diagnose-tsx.js
Original file line number Diff line number Diff line change
@@ -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));
7 changes: 7 additions & 0 deletions src/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,6 +63,10 @@ export default async function DashboardPage() {
<StatsRow userId={user.id} profile={profile} />
</Suspense>

<div className="mb-16">
<MentorshipSessionsSidebar />
</div>

{/* Main Columns */}
<div className="grid grid-cols-1 gap-16 lg:grid-cols-2">
{/* Left Column */}
Expand Down
105 changes: 105 additions & 0 deletions src/app/(app)/maintainer/mentorship/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="p-8 text-white">Supabase service client not configured.</div>;
}

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 (
<div className="min-h-screen bg-[#111318] p-8 text-white">
<div className="mx-auto max-w-6xl space-y-8">
<header className="rounded-3xl border border-slate-800 bg-slate-950 p-8">
<h1 className="text-3xl font-semibold">Mentorship session logs</h1>
<p className="mt-2 text-sm text-slate-400">
Review past mentorship sessions, audit transcripts, and export logs as JSON or CSV.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<a
href="/api/mentorship/logs?format=json"
className="rounded-2xl bg-sky-600 px-4 py-2 text-sm font-semibold text-white hover:bg-sky-500"
>
Export JSON
</a>
<a
href="/api/mentorship/logs?format=csv"
className="rounded-2xl bg-slate-800 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700"
>
Export CSV
</a>
</div>
</header>

<div className="space-y-4">
{(sessions ?? []).map((session: SessionRow) => {
const mentor = session.mentor?.[0];
const mentee = session.mentee?.[0];

return (
<div
key={session.id}
className="rounded-3xl border border-slate-800 bg-slate-900 p-6"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-semibold text-white">Session #{session.id}</h2>
<p className="text-sm text-slate-400">
Mentor: {mentor?.display_name ?? mentor?.github_handle ?? 'Unknown'} · Mentee:{' '}
{mentee?.display_name ?? mentee?.github_handle ?? 'Unknown'}
</p>
</div>
<span className="rounded-full bg-slate-800 px-3 py-1 text-xs uppercase tracking-widest text-slate-400">
Level {session.level}
</span>
</div>
<div className="mt-4 text-sm text-slate-500">
Started {new Date(session.started_at).toLocaleString()} · Ended{' '}
{session.ended_at ? new Date(session.ended_at).toLocaleString() : 'active'}
</div>
</div>
);
})}
{(sessions ?? []).length === 0 && (
<div className="rounded-3xl border border-slate-800 bg-slate-900 p-6 text-slate-500">
No mentorship sessions recorded yet.
</div>
)}
</div>
</div>
</div>
);
}
6 changes: 6 additions & 0 deletions src/app/(app)/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ export default async function MaintainerPage({
>
Community links →
</Link>
<Link
href={`/maintainer/mentorship?install=${activeInstallId}`}
className="rounded-lg border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-600"
>
Mentorship logs →
</Link>
</div>

<p className="mb-4 text-xs text-zinc-500">
Expand Down
2 changes: 2 additions & 0 deletions src/app/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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}`}
/>
</p>
<StartMentorshipChatButton mentorHandle={profile.githubHandle} />
<div className="flex flex-wrap items-center gap-4 text-[11px] uppercase tracking-widest text-zinc-400">
<span>
<span className="font-bold text-white">{profile.prsMerged}</span> PRS MERGED
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -50,5 +51,6 @@ export const { GET, POST, PUT } = serve({
mentorPostComment,
processIssueEvent,
processIssueCommentEvent,
mentorship,
],
});
51 changes: 51 additions & 0 deletions src/app/api/mentorship/create/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
116 changes: 116 additions & 0 deletions src/app/api/mentorship/logs/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
33 changes: 33 additions & 0 deletions src/app/api/mentorship/message/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading