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.
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}
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)
+ })
+})