feat(workspace): mentor + applicant message previews (§F.2)

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
This commit is contained in:
Matt
2026-04-28 16:14:11 +02:00
parent 70a9752d73
commit 26ff8ed111
7 changed files with 352 additions and 0 deletions

View File

@@ -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() {
</AnimatedCard>
))}
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
<MentorConversationCard projectId={project.id} />
{/* Jury Feedback Card */}
{totalEvaluations > 0 && (
<AnimatedCard index={4}>

View File

@@ -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<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -117,6 +118,9 @@ export default function MentorDashboard() {
</p>
</div>
{/* Recent unread messages from teams */}
<RecentMessagesCard />
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<AnimatedCard index={0}>

View File

@@ -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 <Skeleton className="h-44 w-full rounded-md" />
if (!data || !data.mentor) return null // No mentor assigned — render nothing
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<MessageCircle className="h-4 w-4" /> Conversation with{' '}
{data.mentor.name ?? data.mentor.email}
</span>
{data.unreadCount > 0 && <Badge variant="default">{data.unreadCount} new</Badge>}
</CardTitle>
</CardHeader>
<CardContent>
{data.messages.length === 0 ? (
<p className="text-muted-foreground text-sm">
Say hi to your mentor they&apos;re here to help you sharpen your project.
</p>
) : (
<ul className="space-y-2">
{data.messages.map((m) => (
<li key={m.id} className="bg-muted/20 rounded-md border p-2.5">
<div className="text-muted-foreground text-xs">
{m.sender.name ?? m.sender.email}
</div>
<div className="mt-0.5 line-clamp-2 text-sm">{m.message}</div>
</li>
))}
</ul>
)}
<div className="mt-3 flex justify-end">
<Button asChild size="sm">
<Link href="/applicant/mentor">Open chat</Link>
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MessageCircle className="h-4 w-4" /> Recent Messages
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : !data || data.unread.length === 0 ? (
<p className="text-muted-foreground text-sm">
No new messages. Your mentees will appear here when they reach out.
</p>
) : (
<ul className="space-y-3">
{data.unread.map((m) => (
<li key={m.id}>
<Link
href={`/mentor/workspace/${m.project.id}`}
className="hover:bg-muted/40 block rounded-md border p-3"
>
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium">{m.sender.name ?? m.sender.email}</div>
<div className="text-muted-foreground text-xs">
{formatRelativePast(m.createdAt as unknown as Date)}
</div>
</div>
<div className="text-muted-foreground mt-0.5 text-xs">{m.project.title}</div>
<div className="mt-1 line-clamp-2 text-sm">{m.message}</div>
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}

View File

@@ -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,
}
}),
})

View File

@@ -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)
*/

View File

@@ -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)
})
})