feat: Mentees & Activity tab on /admin/mentors
Adds a project-centric ops view for mentor management: - New mentor.getMenteeActivity tRPC procedure aggregates every project with wantsMentorship=true and derives a status (unassigned / assigned / active / stalled) from the latest message + file activity. - /admin/mentors becomes a tabbed page: existing Mentor list + new Mentees & Activity table with status pills, search, and a per-row Assign/Open CTA linking to /admin/projects/[id]/mentor. - Includes 2 unit tests covering classification + program scoping. Also: ignore .remember/ (plugin scratch dir).
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,3 +62,4 @@ build-output.txt
|
|||||||
# Private keys and secrets
|
# Private keys and secrets
|
||||||
private/
|
private/
|
||||||
public/build-id.json
|
public/build-id.json
|
||||||
|
.remember/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -21,7 +22,8 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { ArrowUpDown, Search, Users } from 'lucide-react'
|
import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react'
|
||||||
|
import { formatEnumLabel } from '@/lib/utils'
|
||||||
|
|
||||||
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
|
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
|
||||||
|
|
||||||
@@ -37,6 +39,16 @@ function formatRelativePast(date: Date | string | null): string {
|
|||||||
return `${Math.max(0, minutes)}m ago`
|
return `${Math.max(0, minutes)}m ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<
|
||||||
|
'unassigned' | 'assigned' | 'active' | 'stalled',
|
||||||
|
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||||
|
> = {
|
||||||
|
unassigned: { label: 'Unassigned', variant: 'outline' },
|
||||||
|
assigned: { label: 'Assigned', variant: 'secondary' },
|
||||||
|
active: { label: 'Active', variant: 'default' },
|
||||||
|
stalled: { label: 'Stalled', variant: 'destructive' },
|
||||||
|
}
|
||||||
|
|
||||||
type Mentor = {
|
type Mentor = {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
@@ -50,7 +62,7 @@ type Mentor = {
|
|||||||
lastActivityAt: Date | string | null
|
lastActivityAt: Date | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MentorsListPage() {
|
function MentorListPanel() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('load')
|
const [sortKey, setSortKey] = useState<SortKey>('load')
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
@@ -128,21 +140,6 @@ export default function MentorsListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Mentors</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
All users with the MENTOR role and their current workload.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/admin/members">
|
|
||||||
<Users className="mr-2 h-4 w-4" />
|
|
||||||
Manage Members
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
@@ -279,3 +276,252 @@ export default function MentorsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'unassigned' | 'assigned' | 'active' | 'stalled'
|
||||||
|
|
||||||
|
function MenteeActivityPanel() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.mentor.getMenteeActivity.useQuery({})
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
return data.rows.filter((r) => {
|
||||||
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||||
|
if (!q) return true
|
||||||
|
const hay = [
|
||||||
|
r.project.title,
|
||||||
|
r.project.country ?? '',
|
||||||
|
r.teamLead?.name ?? '',
|
||||||
|
r.teamLead?.email ?? '',
|
||||||
|
r.mentor?.name ?? '',
|
||||||
|
r.mentor?.email ?? '',
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
return hay.includes(q)
|
||||||
|
})
|
||||||
|
}, [data, search, statusFilter])
|
||||||
|
|
||||||
|
const totals = data?.totals ?? { unassigned: 0, assigned: 0, active: 0, stalled: 0 }
|
||||||
|
|
||||||
|
const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(value)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
|
statusFilter === value
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label} <span className="tabular-nums opacity-80">({count})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
Unassigned
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold tabular-nums">{totals.unassigned}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
Assigned
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold tabular-nums">{totals.assigned}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
Active
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold tabular-nums">{totals.active}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
Stalled
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold tabular-nums text-destructive">
|
||||||
|
{totals.stalled}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<CardTitle className="text-base">Mentee teams</CardTitle>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<StatusPill
|
||||||
|
value="all"
|
||||||
|
label="All"
|
||||||
|
count={
|
||||||
|
totals.unassigned + totals.assigned + totals.active + totals.stalled
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusPill value="unassigned" label="Unassigned" count={totals.unassigned} />
|
||||||
|
<StatusPill value="assigned" label="Assigned" count={totals.assigned} />
|
||||||
|
<StatusPill value="active" label="Active" count={totals.active} />
|
||||||
|
<StatusPill value="stalled" label="Stalled" count={totals.stalled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by project, team lead, or mentor…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
{search || statusFilter !== 'all'
|
||||||
|
? 'No matching teams.'
|
||||||
|
: 'No teams have requested mentorship yet.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Mentor</TableHead>
|
||||||
|
<TableHead className="text-right">Messages</TableHead>
|
||||||
|
<TableHead className="text-right">Files</TableHead>
|
||||||
|
<TableHead>Last activity</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const badge = STATUS_BADGE[r.status]
|
||||||
|
return (
|
||||||
|
<TableRow key={r.project.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{r.project.title}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{r.teamLead?.name ?? r.teamLead?.email ?? '—'}
|
||||||
|
{r.project.oceanIssue && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
{formatEnumLabel(r.project.oceanIssue)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={badge.variant} className="text-xs">
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.mentor ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{r.mentor.name ?? r.mentor.email}</div>
|
||||||
|
<div className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{r.mentor.currentLoad}
|
||||||
|
{r.mentor.maxAssignments != null
|
||||||
|
? `/${r.mentor.maxAssignments}`
|
||||||
|
: ''}
|
||||||
|
{' load'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{r.messageCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{r.fileCount}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{formatRelativePast(r.lastActivityAt as unknown as Date | null)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href={`/admin/projects/${r.project.id}/mentor`}>
|
||||||
|
{r.mentor ? 'Open' : 'Assign'}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentorsListPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Mentors</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage the mentor pool and track mentee teams across the program.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin/members">
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Manage Members
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="mentors" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="mentors">
|
||||||
|
<GraduationCap className="mr-2 h-4 w-4" /> Mentors
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="mentees">
|
||||||
|
<Users className="mr-2 h-4 w-4" /> Mentees & Activity
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="mentors">
|
||||||
|
<MentorListPanel />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="mentees">
|
||||||
|
<MenteeActivityPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1072,6 +1072,125 @@ export const mentorRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-centric activity view: every project that wants mentorship,
|
||||||
|
* with assignment status, latest activity timestamps, and a derived
|
||||||
|
* status (unassigned / assigned / active / stalled).
|
||||||
|
* Drives the "Mentees & Activity" tab on /admin/mentors.
|
||||||
|
*/
|
||||||
|
getMenteeActivity: adminProcedure
|
||||||
|
.input(z.object({ programId: z.string().optional() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
wantsMentorship: true,
|
||||||
|
...(input.programId ? { programId: input.programId } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
country: true,
|
||||||
|
status: true,
|
||||||
|
oceanIssue: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
mentorAssignment: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
method: true,
|
||||||
|
assignedAt: true,
|
||||||
|
completionStatus: true,
|
||||||
|
mentor: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: { select: { mentorAssignments: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: { createdAt: true },
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: { createdAt: true },
|
||||||
|
},
|
||||||
|
_count: { select: { messages: true, files: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teamMembers: {
|
||||||
|
where: { role: 'LEAD' },
|
||||||
|
take: 1,
|
||||||
|
select: { user: { select: { name: true, email: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { title: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const ACTIVE_WINDOW_MS = 7 * 86_400_000
|
||||||
|
const STALLED_WINDOW_MS = 14 * 86_400_000
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const totals = { unassigned: 0, assigned: 0, active: 0, stalled: 0 }
|
||||||
|
|
||||||
|
const rows = projects.map((p) => {
|
||||||
|
const ma = p.mentorAssignment
|
||||||
|
const lastMessageAt = ma?.messages[0]?.createdAt ?? null
|
||||||
|
const lastFileAt = ma?.files[0]?.createdAt ?? null
|
||||||
|
const lastActivityAt = [lastMessageAt, lastFileAt]
|
||||||
|
.filter((d): d is Date => d != null)
|
||||||
|
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null
|
||||||
|
|
||||||
|
let status: 'unassigned' | 'assigned' | 'active' | 'stalled'
|
||||||
|
if (!ma) {
|
||||||
|
status = 'unassigned'
|
||||||
|
} else if (lastActivityAt && now - lastActivityAt.getTime() <= ACTIVE_WINDOW_MS) {
|
||||||
|
status = 'active'
|
||||||
|
} else {
|
||||||
|
const referenceTime = lastActivityAt ?? ma.assignedAt
|
||||||
|
const elapsed = now - referenceTime.getTime()
|
||||||
|
status = elapsed > STALLED_WINDOW_MS ? 'stalled' : 'assigned'
|
||||||
|
}
|
||||||
|
totals[status]++
|
||||||
|
|
||||||
|
const teamLead = p.teamMembers[0]?.user ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
country: p.country,
|
||||||
|
status: p.status,
|
||||||
|
oceanIssue: p.oceanIssue,
|
||||||
|
competitionCategory: p.competitionCategory,
|
||||||
|
},
|
||||||
|
teamLead: teamLead ? { name: teamLead.name, email: teamLead.email } : null,
|
||||||
|
mentor: ma?.mentor
|
||||||
|
? {
|
||||||
|
id: ma.mentor.id,
|
||||||
|
name: ma.mentor.name,
|
||||||
|
email: ma.mentor.email,
|
||||||
|
currentLoad: ma.mentor._count.mentorAssignments,
|
||||||
|
maxAssignments: ma.mentor.maxAssignments,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
assignmentMethod: ma?.method ?? null,
|
||||||
|
assignedAt: ma?.assignedAt ?? null,
|
||||||
|
lastMessageAt,
|
||||||
|
lastFileAt,
|
||||||
|
lastActivityAt,
|
||||||
|
messageCount: ma?._count.messages ?? 0,
|
||||||
|
fileCount: ma?._count.files ?? 0,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { rows, totals }
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get mentor's assigned projects
|
* Get mentor's assigned projects
|
||||||
*/
|
*/
|
||||||
|
|||||||
167
tests/unit/mentee-activity.test.ts
Normal file
167
tests/unit/mentee-activity.test.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { mentorRouter } from '../../src/server/routers/mentor'
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86_400_000
|
||||||
|
|
||||||
|
describe('mentor.getMenteeActivity', () => {
|
||||||
|
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('classifies projects as unassigned / assigned / active / stalled and returns totals', async () => {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
const program = await createTestProgram({ name: `mentee-activity-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
|
||||||
|
const lead = await createUserWithRoles('APPLICANT', ['APPLICANT'])
|
||||||
|
userIds.push(lead.id)
|
||||||
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||||
|
userIds.push(mentor.id)
|
||||||
|
|
||||||
|
// Four projects all wantsMentorship
|
||||||
|
const pUnassigned = await createTestProject(program.id, { title: 'Unassigned' })
|
||||||
|
const pAssigned = await createTestProject(program.id, { title: 'Assigned' })
|
||||||
|
const pActive = await createTestProject(program.id, { title: 'Active' })
|
||||||
|
const pStalled = await createTestProject(program.id, { title: 'Stalled' })
|
||||||
|
for (const p of [pUnassigned, pAssigned, pActive, pStalled]) {
|
||||||
|
await prisma.project.update({ where: { id: p.id }, data: { wantsMentorship: true } })
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: { projectId: p.id, userId: lead.id, role: 'LEAD' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigned: mentor assigned, no activity yet
|
||||||
|
await prisma.mentorAssignment.create({
|
||||||
|
data: {
|
||||||
|
projectId: pAssigned.id,
|
||||||
|
mentorId: mentor.id,
|
||||||
|
method: 'MANUAL',
|
||||||
|
assignedBy: admin.id,
|
||||||
|
workspaceEnabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Active: mentor + recent message
|
||||||
|
const aActive = await prisma.mentorAssignment.create({
|
||||||
|
data: {
|
||||||
|
projectId: pActive.id,
|
||||||
|
mentorId: mentor.id,
|
||||||
|
method: 'MANUAL',
|
||||||
|
assignedBy: admin.id,
|
||||||
|
workspaceEnabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await prisma.mentorMessage.create({
|
||||||
|
data: {
|
||||||
|
projectId: pActive.id,
|
||||||
|
senderId: mentor.id,
|
||||||
|
message: 'recent ping',
|
||||||
|
workspaceId: aActive.id,
|
||||||
|
createdAt: new Date(Date.now() - 2 * DAY),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stalled: mentor + last message > 14 days ago
|
||||||
|
const aStalled = await prisma.mentorAssignment.create({
|
||||||
|
data: {
|
||||||
|
projectId: pStalled.id,
|
||||||
|
mentorId: mentor.id,
|
||||||
|
method: 'MANUAL',
|
||||||
|
assignedBy: admin.id,
|
||||||
|
workspaceEnabled: true,
|
||||||
|
assignedAt: new Date(Date.now() - 30 * DAY),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await prisma.mentorMessage.create({
|
||||||
|
data: {
|
||||||
|
projectId: pStalled.id,
|
||||||
|
senderId: mentor.id,
|
||||||
|
message: 'old ping',
|
||||||
|
workspaceId: aStalled.id,
|
||||||
|
createdAt: new Date(Date.now() - 20 * DAY),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const caller = createCaller(mentorRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
const result = await caller.getMenteeActivity({ programId: program.id })
|
||||||
|
|
||||||
|
const byTitle = Object.fromEntries(
|
||||||
|
result.rows.map((r: (typeof result.rows)[number]) => [r.project.title, r]),
|
||||||
|
)
|
||||||
|
expect(byTitle['Unassigned'].status).toBe('unassigned')
|
||||||
|
expect(byTitle['Unassigned'].mentor).toBeNull()
|
||||||
|
expect(byTitle['Assigned'].status).toBe('assigned')
|
||||||
|
expect(byTitle['Assigned'].mentor?.id).toBe(mentor.id)
|
||||||
|
expect(byTitle['Active'].status).toBe('active')
|
||||||
|
expect(byTitle['Active'].lastActivityAt).not.toBeNull()
|
||||||
|
expect(byTitle['Stalled'].status).toBe('stalled')
|
||||||
|
|
||||||
|
expect(result.totals.unassigned).toBe(1)
|
||||||
|
expect(result.totals.assigned).toBe(1)
|
||||||
|
expect(result.totals.active).toBe(1)
|
||||||
|
expect(result.totals.stalled).toBe(1)
|
||||||
|
|
||||||
|
// Team lead resolved
|
||||||
|
expect(byTitle['Active'].teamLead?.email).toBe(lead.email)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only includes projects that wantMentorship within the program scope', async () => {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
const program = await createTestProgram({ name: `mentee-scope-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
|
||||||
|
const pYes = await createTestProject(program.id, { title: 'Yes' })
|
||||||
|
const pNo = await createTestProject(program.id, { title: 'No' })
|
||||||
|
await prisma.project.update({ where: { id: pYes.id }, data: { wantsMentorship: true } })
|
||||||
|
await prisma.project.update({ where: { id: pNo.id }, data: { wantsMentorship: false } })
|
||||||
|
|
||||||
|
const caller = createCaller(mentorRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
const result = await caller.getMenteeActivity({ programId: program.id })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.rows.map((r: (typeof result.rows)[number]) => r.project.title).sort(),
|
||||||
|
).toEqual(['Yes'])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user