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 { Textarea } from '@/components/ui/textarea'
|
||||||
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||||
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||||
|
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
@@ -401,6 +402,10 @@ export default function ApplicantDashboardPage() {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
|
||||||
|
<MentorConversationCard projectId={project.id} />
|
||||||
|
|
||||||
|
|
||||||
{/* Jury Feedback Card */}
|
{/* Jury Feedback Card */}
|
||||||
{totalEvaluations > 0 && (
|
{totalEvaluations > 0 && (
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={4}>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
import { RecentMessagesCard } from '@/components/mentor/recent-messages-card'
|
||||||
|
|
||||||
// Status badge colors
|
// Status badge colors
|
||||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -117,6 +118,9 @@ export default function MentorDashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recent unread messages from teams */}
|
||||||
|
<RecentMessagesCard />
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<AnimatedCard index={0}>
|
<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 }
|
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
|
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)
|
* List all mentor assignments (admin)
|
||||||
*/
|
*/
|
||||||
|
|||||||
147
tests/unit/workspace-previews.test.ts
Normal file
147
tests/unit/workspace-previews.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user