From 26ff8ed111559a81cf680359fc63639e9ccb6875 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 16:14:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20mentor=20+=20applicant=20mes?= =?UTF-8?q?sage=20previews=20(=C2=A7F.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mentor.getRecentMessages: last N unread messages from teams across all of a mentor's assignments. Drives a Recent Messages card on /mentor. applicant.getMentorConversationPreview: last 3 messages + unread count for a given project. Drives a 'Conversation with [Mentor]' card on /applicant — auto-hides when no mentor is assigned. Both procedures use the existing MentorMessage(projectId, createdAt) composite index — no new index needed. Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md --- src/app/(applicant)/applicant/page.tsx | 5 + src/app/(mentor)/mentor/page.tsx | 4 + .../applicant/mentor-conversation-card.tsx | 60 +++++++ .../mentor/recent-messages-card.tsx | 66 ++++++++ src/server/routers/applicant.ts | 46 ++++++ src/server/routers/mentor.ts | 24 +++ tests/unit/workspace-previews.test.ts | 147 ++++++++++++++++++ 7 files changed, 352 insertions(+) create mode 100644 src/components/applicant/mentor-conversation-card.tsx create mode 100644 src/components/mentor/recent-messages-card.tsx create mode 100644 tests/unit/workspace-previews.test.ts diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 0ef5337..33dbbc6 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -17,6 +17,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline' import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card' +import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card' import { AnimatedCard } from '@/components/shared/animated-container' import { ProjectLogoUpload } from '@/components/shared/project-logo-upload' import { Progress } from '@/components/ui/progress' @@ -401,6 +402,10 @@ export default function ApplicantDashboardPage() { ))} + {/* Conversation with assigned mentor (auto-hides when no mentor assigned) */} + + + {/* Jury Feedback Card */} {totalEvaluations > 0 && ( diff --git a/src/app/(mentor)/mentor/page.tsx b/src/app/(mentor)/mentor/page.tsx index b5073d4..6a2f18a 100644 --- a/src/app/(mentor)/mentor/page.tsx +++ b/src/app/(mentor)/mentor/page.tsx @@ -41,6 +41,7 @@ import { import { formatDateOnly } from '@/lib/utils' import { AnimatedCard } from '@/components/shared/animated-container' import { CountryDisplay } from '@/components/shared/country-display' +import { RecentMessagesCard } from '@/components/mentor/recent-messages-card' // Status badge colors const statusColors: Record = { @@ -117,6 +118,9 @@ export default function MentorDashboard() {

+ {/* Recent unread messages from teams */} + + {/* Stats */}
diff --git a/src/components/applicant/mentor-conversation-card.tsx b/src/components/applicant/mentor-conversation-card.tsx new file mode 100644 index 0000000..3aaaaa2 --- /dev/null +++ b/src/components/applicant/mentor-conversation-card.tsx @@ -0,0 +1,60 @@ +'use client' + +import Link from 'next/link' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { MessageCircle } from 'lucide-react' + +interface Props { + projectId: string +} + +export function MentorConversationCard({ projectId }: Props) { + const { data, isLoading } = trpc.applicant.getMentorConversationPreview.useQuery( + { projectId, limit: 3 }, + { refetchInterval: 30_000 }, + ) + + if (isLoading) return + if (!data || !data.mentor) return null // No mentor assigned — render nothing + + return ( + + + + + Conversation with{' '} + {data.mentor.name ?? data.mentor.email} + + {data.unreadCount > 0 && {data.unreadCount} new} + + + + {data.messages.length === 0 ? ( +

+ Say hi to your mentor — they're here to help you sharpen your project. +

+ ) : ( +
    + {data.messages.map((m) => ( +
  • +
    + {m.sender.name ?? m.sender.email} +
    +
    {m.message}
    +
  • + ))} +
+ )} +
+ +
+
+
+ ) +} diff --git a/src/components/mentor/recent-messages-card.tsx b/src/components/mentor/recent-messages-card.tsx new file mode 100644 index 0000000..c3ca434 --- /dev/null +++ b/src/components/mentor/recent-messages-card.tsx @@ -0,0 +1,66 @@ +'use client' + +import Link from 'next/link' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { MessageCircle } from 'lucide-react' + +function formatRelativePast(date: Date | string | null): string { + if (!date) return '' + const d = typeof date === 'string' ? new Date(date) : date + const ms = Date.now() - d.getTime() + const minutes = Math.floor(ms / 60_000) + const hours = Math.floor(ms / 3_600_000) + const days = Math.floor(ms / 86_400_000) + if (days > 0) return `${days}d ago` + if (hours > 0) return `${hours}h ago` + return `${Math.max(0, minutes)}m ago` +} + +export function RecentMessagesCard() { + const { data, isLoading } = trpc.mentor.getRecentMessages.useQuery({ limit: 5 }) + + return ( + + + + Recent Messages + + + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : !data || data.unread.length === 0 ? ( +

+ No new messages. Your mentees will appear here when they reach out. +

+ ) : ( +
    + {data.unread.map((m) => ( +
  • + +
    +
    {m.sender.name ?? m.sender.email}
    +
    + {formatRelativePast(m.createdAt as unknown as Date)} +
    +
    +
    {m.project.title}
    +
    {m.message}
    + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 7bc8594..9104706 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -2659,4 +2659,50 @@ export const applicantRouter = router({ return { success: true, roundId: activePrs.roundId, roundName: activePrs.round.name } }), + + /** + * Last N messages + unread count for the applicant's project mentor workspace. + * Drives the 'Conversation with [Mentor]' card on /applicant. + */ + getMentorConversationPreview: protectedProcedure + .input(z.object({ projectId: z.string(), limit: z.number().min(1).max(10).default(3) })) + .query(async ({ ctx, input }) => { + const teamMembership = await ctx.prisma.teamMember.findFirst({ + where: { projectId: input.projectId, userId: ctx.user.id }, + select: { id: true }, + }) + if (!teamMembership) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Not a team member of this project', + }) + } + + const assignment = await ctx.prisma.mentorAssignment.findUnique({ + where: { projectId: input.projectId }, + include: { mentor: { select: { id: true, name: true, email: true } } }, + }) + + const [messages, unreadCount] = await Promise.all([ + ctx.prisma.mentorMessage.findMany({ + where: { projectId: input.projectId }, + include: { sender: { select: { id: true, name: true, email: true } } }, + orderBy: { createdAt: 'desc' }, + take: input.limit, + }), + ctx.prisma.mentorMessage.count({ + where: { + projectId: input.projectId, + senderId: { not: ctx.user.id }, + isRead: false, + }, + }), + ]) + + return { + mentor: assignment?.mentor ?? null, + messages: messages.reverse(), + unreadCount, + } + }), }) diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 0205cff..affeaec 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1259,6 +1259,30 @@ export const mentorRouter = router({ return messages }), + /** + * Recent unread messages from team members across all of the mentor's + * assignments. Drives the 'Recent Messages' card on /mentor. + */ + getRecentMessages: mentorProcedure + .input(z.object({ limit: z.number().min(1).max(20).default(5) }).optional()) + .query(async ({ ctx, input }) => { + const limit = input?.limit ?? 5 + const unread = await ctx.prisma.mentorMessage.findMany({ + where: { + senderId: { not: ctx.user.id }, + isRead: false, + workspace: { mentorId: ctx.user.id }, + }, + include: { + sender: { select: { id: true, name: true, email: true } }, + project: { select: { id: true, title: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }) + return { unread } + }), + /** * List all mentor assignments (admin) */ diff --git a/tests/unit/workspace-previews.test.ts b/tests/unit/workspace-previews.test.ts new file mode 100644 index 0000000..69d3969 --- /dev/null +++ b/tests/unit/workspace-previews.test.ts @@ -0,0 +1,147 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { mentorRouter } from '../../src/server/routers/mentor' +import { applicantRouter } from '../../src/server/routers/applicant' +import type { UserRole } from '@prisma/client' + +async function createUserWithRoles(primaryRole: UserRole, rolesArray: UserRole[]) { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: `Test ${primaryRole}`, + role: primaryRole, + roles: rolesArray, + status: 'ACTIVE', + }, + }) +} + +describe('mentor.getRecentMessages', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('returns recent unread messages from team across mentor assignments', async () => { + const program = await createTestProgram({ name: `recent-msgs-${uid()}` }) + programIds.push(program.id) + const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) + const applicant = await createUserWithRoles('APPLICANT', ['APPLICANT']) + userIds.push(mentor.id, applicant.id) + const project = await createTestProject(program.id) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + workspaceEnabled: true, + }, + }) + await prisma.mentorMessage.create({ + data: { + projectId: project.id, + senderId: applicant.id, + message: 'Question 1', + workspaceId: ma.id, + isRead: false, + }, + }) + await prisma.mentorMessage.create({ + data: { + projectId: project.id, + senderId: applicant.id, + message: 'Question 2', + workspaceId: ma.id, + isRead: false, + }, + }) + await prisma.mentorMessage.create({ + data: { + projectId: project.id, + senderId: applicant.id, + message: 'old', + workspaceId: ma.id, + isRead: true, + }, + }) + + const caller = createCaller(mentorRouter, { id: mentor.id, email: mentor.email, role: 'MENTOR' }) + const result = await caller.getRecentMessages({ limit: 5 }) + + expect(result.unread.length).toBe(2) + expect(result.unread[0].message).toContain('Question') + }) +}) + +describe('applicant.getMentorConversationPreview', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('returns last 3 messages + unread count for the project', async () => { + const program = await createTestProgram({ name: `conv-preview-${uid()}` }) + programIds.push(program.id) + const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) + const applicant = await createUserWithRoles('APPLICANT', ['APPLICANT']) + userIds.push(mentor.id, applicant.id) + const project = await createTestProject(program.id) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: applicant.id, role: 'LEAD' }, + }) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + workspaceEnabled: true, + }, + }) + for (let i = 0; i < 5; i++) { + await prisma.mentorMessage.create({ + data: { + projectId: project.id, + senderId: mentor.id, + message: `mentor msg ${i}`, + workspaceId: ma.id, + isRead: i < 2, + }, + }) + } + + const caller = createCaller(applicantRouter, { + id: applicant.id, + email: applicant.email, + role: 'APPLICANT', + }) + const result = await caller.getMentorConversationPreview({ projectId: project.id }) + + expect(result.messages.length).toBe(3) + expect(result.unreadCount).toBe(3) + expect(result.mentor?.id).toBe(mentor.id) + }) +})