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