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:
Matt
2026-04-28 16:47:53 +02:00
parent 11ab0943f6
commit d0058b46ed
4 changed files with 550 additions and 17 deletions

View File

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