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:
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
60
src/components/applicant/mentor-conversation-card.tsx
Normal file
60
src/components/applicant/mentor-conversation-card.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
66
src/components/mentor/recent-messages-card.tsx
Normal file
66
src/components/mentor/recent-messages-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user