Files
MOPC-Portal/src/server/routers/mentor.ts

3220 lines
106 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
import {
MentorAssignmentMethod,
MentorChangeRequestStatus,
Prisma,
type PrismaClient,
} from '@prisma/client'
import {
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
sendMentorBulkAssignmentEmail,
sendMentorChangeRequestEmail,
sendMentorTeamAssignmentEmail,
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
import {
getAIMentorSuggestions,
getRoundRobinMentor,
computeExpertiseOverlap,
} from '../services/mentor-matching'
import { getOpenAI } from '@/lib/openai'
import {
createNotification,
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
import {
activateWorkspace,
sendMessage as workspaceSendMessage,
getMessages as workspaceGetMessages,
markRead as workspaceMarkRead,
uploadFile as workspaceUploadFile,
addFileComment as workspaceAddFileComment,
promoteFile as workspacePromoteFile,
getFiles as workspaceGetFilesService,
deleteFile as workspaceDeleteFileService,
} from '../services/mentor-workspace'
import { triggerInProgressOnActivity } from '../services/round-engine'
import {
generateMentorObjectKey,
getPresignedUrl,
BUCKET_NAME,
deleteObject,
} from '@/lib/minio'
import {
signMentorUploadToken,
verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
/**
* True if the project is enrolled in a MENTORING round that is still
* ROUND_DRAFT. Used to defer mentor- and team-side emails until the round
* opens, so admins can stage assignments without sending notifications.
* If the project isn't in a MENTORING round at all, returns false
* (i.e. send emails normally there's no round-open event to wait for).
*/
async function shouldDeferEmailsForProject(
prisma: PrismaClient,
projectId: string,
): Promise<boolean> {
const draftRoundEnrollment = await prisma.projectRoundState.findFirst({
where: {
projectId,
round: { roundType: 'MENTORING', status: 'ROUND_DRAFT' },
},
select: { id: true },
})
return draftRoundEnrollment !== null
}
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
/**
* Introduce the project team to ALL active mentors via email IF the project's
* MENTORING round is currently ROUND_ACTIVE. Idempotent: only emails mentors
* whose assignment row has `teamIntroducedAt: null`. If the round is not yet
* active, this is a no-op the activation step will fire the email instead.
* Never throws.
*/
async function introduceTeamToMentorsIfRoundOpen(
prisma: PrismaClient,
projectId: string,
): Promise<void> {
try {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
id: true,
title: true,
projectRoundStates: {
where: {
round: { roundType: 'MENTORING', status: 'ROUND_ACTIVE' },
},
select: { id: true },
take: 1,
},
mentorAssignments: {
where: { droppedAt: null, teamIntroducedAt: null },
select: {
id: true,
mentor: { select: { name: true, email: true } },
},
},
teamMembers: {
select: { user: { select: { name: true, email: true } } },
},
submittedByEmail: true,
submittedBy: { select: { name: true } },
},
})
if (!project) return
if (project.projectRoundStates.length === 0) return // round not active yet
const mentors = project.mentorAssignments
.filter((a) => a.mentor?.email)
.map((a) => ({ name: a.mentor.name, email: a.mentor.email }))
if (mentors.length === 0) return
const recipients = new Map<string, { name: string | null }>()
for (const tm of project.teamMembers) {
if (tm.user?.email) {
recipients.set(tm.user.email, { name: tm.user.name })
}
}
if (
project.submittedByEmail &&
!recipients.has(project.submittedByEmail)
) {
recipients.set(project.submittedByEmail, {
name: project.submittedBy?.name ?? null,
})
}
for (const [email, { name }] of recipients) {
await sendTeamMentorIntroductionEmail(
email,
name,
project.title,
project.id,
mentors,
)
}
await prisma.mentorAssignment.updateMany({
where: { id: { in: project.mentorAssignments.map((a) => a.id) } },
data: { teamIntroducedAt: new Date() },
})
} catch (e) {
console.error('[introduceTeamToMentorsIfRoundOpen] failed (non-fatal):', e)
}
}
/**
* Throws TRPCError if the given user is neither the assigned mentor
* nor a team member of the project linked to the assignment.
* Returns the loaded MentorAssignment + Project on success.
*/
async function assertWorkspaceAccess(
prisma: PrismaClient,
userId: string,
mentorAssignmentId: string,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: mentorAssignmentId },
include: { project: { select: { id: true, title: true } } },
})
if (!assignment) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Mentor assignment not found' })
}
if (!assignment.workspaceEnabled) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
}
if (assignment.mentorId === userId) return assignment
const teamMembership = await prisma.teamMember.findFirst({
where: { projectId: assignment.projectId, userId },
select: { id: true },
})
if (teamMembership) return assignment
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}
/**
* Project-scoped workspace access check (PR8 multi-mentor).
*
* Allowed when the user is either:
* 1) currently assigned as a mentor on this project (droppedAt = null), OR
* 2) a team member of the project.
*
* Also requires at least one active mentor assignment for the project with
* workspaceEnabled = true meaning the project actually has a live workspace.
* Throws TRPCError on failure. Returns nothing on success.
*/
async function assertProjectWorkspaceAccess(
prisma: PrismaClient,
userId: string,
projectId: string,
): Promise<void> {
const liveMentorAssignment = await prisma.mentorAssignment.findFirst({
where: { projectId, droppedAt: null, workspaceEnabled: true },
select: { id: true },
})
if (!liveMentorAssignment) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
}
const mentorOnProject = await prisma.mentorAssignment.findFirst({
where: { projectId, mentorId: userId, droppedAt: null },
select: { id: true },
})
if (mentorOnProject) return
const teamMembership = await prisma.teamMember.findFirst({
where: { projectId, userId },
select: { id: true },
})
if (teamMembership) return
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}
export const mentorRouter = router({
/**
* Get AI-suggested mentor matches for a project
*/
getSuggestions: adminProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().min(1).max(10).default(5),
})
)
.query(async ({ ctx, input }) => {
// Verify project exists
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
mentorAssignments: true,
},
})
// With multi-mentor (PR8) the project can have several mentors. The
// suggestions endpoint is informational — return whatever AI suggests
// and let `mentor.assign` enforce per-pair uniqueness. We still surface
// an existing primary mentor in the payload so UIs can label it.
const primaryMentor = project.mentorAssignments[0] ?? null
// Detect AI configuration so the UI can label "AI matching unavailable"
// when we fall back to algorithmic ranking. An AI error mid-call still
// reports source: 'ai' — accepted imprecision in exchange for a small diff.
const openai = await getOpenAI()
const source: 'ai' | 'fallback' = openai ? 'ai' : 'fallback'
const suggestions = await getAIMentorSuggestions(
ctx.prisma,
input.projectId,
input.limit
)
// Enrich with mentor details (batch query to avoid N+1)
const mentorIds = suggestions.map((s) => s.mentorId)
const mentors = await ctx.prisma.user.findMany({
where: { id: { in: mentorIds } },
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
mentorAssignments: {
select: { id: true },
},
},
})
const mentorMap = new Map(mentors.map((m) => [m.id, m]))
const enrichedSuggestions = suggestions.map((suggestion) => {
const mentor = mentorMap.get(suggestion.mentorId)
return {
...suggestion,
mentor: mentor
? {
id: mentor.id,
name: mentor.name,
email: mentor.email,
expertiseTags: mentor.expertiseTags,
assignmentCount: mentor.mentorAssignments.length,
}
: null,
}
})
return {
// TODO(PR8 Task 8): return the full mentor list. Legacy field kept
// until the admin UI is updated.
currentMentor: primaryMentor,
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
source,
message: null,
}
}),
/**
* List all MENTOR-role users with expertise overlap %, current load, capacity,
* and country. Drives the manual-picker tab on /admin/projects/[id]/mentor.
* Sorted by overlap desc, then by current load asc.
*/
getCandidates: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: {
id: true,
oceanIssue: true,
competitionCategory: true,
tags: true,
description: true,
},
})
const mentors = await ctx.prisma.user.findMany({
where: {
roles: { has: 'MENTOR' },
status: { not: 'SUSPENDED' },
},
select: {
id: true,
name: true,
email: true,
country: true,
expertiseTags: true,
maxAssignments: true,
mentorAssignments: { where: { droppedAt: null }, select: { id: true } },
},
})
const candidates = mentors.map((m) => {
const { score, matchedCount } = computeExpertiseOverlap(project, m.expertiseTags)
return {
id: m.id,
name: m.name,
email: m.email,
country: m.country,
expertiseTags: m.expertiseTags,
currentAssignments: m.mentorAssignments.length,
maxAssignments: m.maxAssignments,
overlapScore: score,
matchedKeywords: matchedCount,
}
})
candidates.sort(
(a, b) =>
b.overlapScore - a.overlapScore || a.currentAssignments - b.currentAssignments,
)
return { candidates }
}),
/**
* Manually assign a mentor to a project
*/
assign: adminProcedure
.input(
z.object({
projectId: z.string(),
mentorId: z.string(),
method: z.nativeEnum(MentorAssignmentMethod).default('MANUAL'),
aiConfidenceScore: z.number().optional(),
expertiseMatchScore: z.number().optional(),
aiReasoning: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify project exists (multi-mentor: stacking is allowed; duplicate
// (projectId, mentorId) pairs are rejected by the unique constraint
// below).
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
})
// Verify mentor exists
const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId },
})
// Create assignment. P2002 on the composite (projectId, mentorId) unique
// constraint means this exact mentor is already on this team — surface a
// friendly error.
let assignment
try {
assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
},
},
})
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
throw new TRPCError({
code: 'CONFLICT',
message: 'This mentor is already assigned to that project.',
})
}
throw err
}
// Audit outside transaction so failures don't roll back the assignment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId: input.mentorId,
mentorName: assignment.mentor.name,
method: input.method,
// PR8: per-team assignment (one row per mentor-project pair).
assignmentScope: 'per-team',
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Get team lead info for mentor notification
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor of new mentee
await createNotification({
userId: input.mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team of mentor assignment
await notifyProjectTeam(input.projectId, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
// Defer the mentor-side email if the project's MENTORING round is still
// ROUND_DRAFT — `activateRound` will coalesce and send when the admin
// opens the round. Otherwise fire the per-assignment email immediately.
const deferThisEmail = await shouldDeferEmailsForProject(
ctx.prisma,
input.projectId,
)
if (
!deferThisEmail &&
assignment.notificationSentAt == null &&
assignment.mentor.email
) {
await sendMentorTeamAssignmentEmail(
assignment.mentor.email,
assignment.mentor.name,
assignment.project.title,
input.projectId,
)
try {
await ctx.prisma.mentorAssignment.update({
where: { id: assignment.id },
data: { notificationSentAt: new Date() },
})
} catch (e) {
console.error('[Mentor] failed to stamp notificationSentAt (non-fatal):', e)
}
}
// Auto-transition: mark project IN_PROGRESS in any active MENTORING round
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: input.projectId,
round: { roundType: 'MENTORING', status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
state: 'PENDING',
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(input.projectId, mentoringPrs.roundId, ctx.user.id, ctx.prisma)
}
} catch (e) {
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
}
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
// If the project's MENTORING round is already open, introduce the team
// to their mentor(s) by email now. Otherwise the activation hook fires it.
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, input.projectId)
return assignment
}),
/**
* Auto-assign a mentor using AI or round-robin
*/
autoAssign: adminProcedure
.input(
z.object({
projectId: z.string(),
useAI: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify project exists and doesn't already have a mentor. Multi-mentor
// stacking is reserved for explicit admin assignment via `mentor.assign`;
// auto-assignment skips projects that already have at least one mentor
// to avoid double-AI-assignments.
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { mentorAssignments: { select: { id: true } } },
})
if (project.mentorAssignments.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Project already has a mentor assigned',
})
}
let mentorId: string | null = null
let method: MentorAssignmentMethod = 'ALGORITHM'
let aiConfidenceScore: number | undefined
let expertiseMatchScore: number | undefined
let aiReasoning: string | undefined
if (input.useAI) {
// Try AI matching first
const suggestions = await getAIMentorSuggestions(ctx.prisma, input.projectId, 1)
if (suggestions.length > 0) {
const best = suggestions[0]
mentorId = best.mentorId
method = 'AI_AUTO'
aiConfidenceScore = best.confidenceScore
expertiseMatchScore = best.expertiseMatchScore
aiReasoning = best.reasoning
}
}
// Fallback to round-robin
if (!mentorId) {
mentorId = await getRoundRobinMentor(ctx.prisma)
method = 'ALGORITHM'
}
if (!mentorId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No available mentors found',
})
}
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId,
method,
assignedBy: ctx.user.id,
aiConfidenceScore,
expertiseMatchScore,
aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
},
},
})
// Create audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId,
mentorName: assignment.mentor.name,
method,
aiConfidenceScore,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Get team lead info for mentor notification
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor of new mentee
await createNotification({
userId: mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team of mentor assignment
await notifyProjectTeam(input.projectId, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
return assignment
}),
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
/**
* Bulk-assign MANY mentors to MANY projects (cartesian product) in one
* call. Skips (mentor, project) pairs where the mentor is already an
* active mentor on that project. Each affected mentor receives ONE
* coalesced email listing only their newly-assigned projects. Each team
* whose project's MENTORING round is already open receives ONE intro
* email listing all their active mentors (including any pre-existing).
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
*/
bulkAssign: adminProcedure
.input(
z.object({
mentorIds: z.array(z.string()).min(1),
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
projectIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const mentors = await ctx.prisma.user.findMany({
where: { id: { in: input.mentorIds } },
select: { id: true, name: true, email: true, roles: true },
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
})
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR'))
if (validMentors.length === 0) {
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'None of the selected users have the MENTOR role',
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
})
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds } },
select: {
id: true,
title: true,
mentorAssignments: {
where: {
mentorId: { in: validMentors.map((m) => m.id) },
droppedAt: null,
},
select: { mentorId: true },
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
},
},
})
// Track per-mentor (for emails) and per-project (for team intros) state.
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
newProjects: { id: string; title: string }[]
skippedProjects: { id: string; title: string }[]
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
}
>()
for (const m of validMentors) {
perMentor.set(m.id, {
email: m.email ?? null,
name: m.name ?? null,
assignmentIds: [],
newProjects: [],
skippedProjects: [],
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
})
}
const touchedProjectIds = new Set<string>()
let totalAssigned = 0
let totalSkipped = 0
for (const project of projects) {
const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
for (const mentor of validMentors) {
const bucket = perMentor.get(mentor.id)!
if (alreadyOn.has(mentor.id)) {
bucket.skippedProjects.push({ id: project.id, title: project.title })
totalSkipped++
continue
}
const created = await ctx.prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: ctx.user.id,
},
})
bucket.assignmentIds.push(created.id)
bucket.newProjects.push({ id: project.id, title: project.title })
touchedProjectIds.add(project.id)
totalAssigned++
await createNotification({
userId: mentor.id,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${project.title}".`,
linkUrl: `/mentor/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: { projectName: project.title },
})
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
await notifyProjectTeam(project.id, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: { projectName: project.title, mentorName: mentor.name },
})
}
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
// Best-effort: mark project IN_PROGRESS in the active MENTORING round
if (touchedProjectIds.has(project.id)) {
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: project.id,
round: {
roundType: 'MENTORING',
status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
},
state: 'PENDING',
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(
project.id,
mentoringPrs.roundId,
ctx.user.id,
ctx.prisma,
)
}
} catch (e) {
console.error(
'[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):',
e,
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
)
}
}
}
// Decide per-project whether to defer email until round-open: projects
// whose MENTORING round is still ROUND_DRAFT skip email and stamp now;
// `activateRound` will coalesce and send when the admin opens the round.
const draftProjectIds = new Set<string>()
for (const projectId of touchedProjectIds) {
if (await shouldDeferEmailsForProject(ctx.prisma, projectId)) {
draftProjectIds.add(projectId)
}
}
// One email per mentor, listing only their NEW projects whose mentoring
// round is NOT in draft. If every new project is deferred, no email.
for (const bucket of perMentor.values()) {
if (!bucket.email) continue
const sendableProjects = bucket.newProjects.filter(
(p) => !draftProjectIds.has(p.id),
)
if (sendableProjects.length === 0) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
sendableProjects,
)
// Only stamp notificationSentAt for the assignments that correspond
// to projects we actually emailed about. Draft-deferred ones stay
// unstamped so activateRound picks them up.
const sendableProjectIds = new Set(sendableProjects.map((p) => p.id))
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
await ctx.prisma.mentorAssignment.updateMany({
where: {
id: { in: bucket.assignmentIds },
projectId: { in: Array.from(sendableProjectIds) },
},
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
data: { notificationSentAt: new Date() },
})
}
// Team-intro email per touched project (only fires if the round is
// already ROUND_ACTIVE — the helper short-circuits otherwise, so draft
// projects are naturally deferred to activateRound's intro pass).
for (const projectId of touchedProjectIds) {
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'BulkAssign',
entityId: 'multi',
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
detailsJson: {
mentorIds: validMentors.map((m) => m.id),
projectIds: input.projectIds,
totalAssigned,
totalSkipped,
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
mentorId: id,
assigned: b.newProjects.length,
skipped: b.skippedProjects.length,
})),
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
totalAssigned,
totalSkipped,
touchedProjectCount: touchedProjectIds.size,
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
mentorId: id,
mentorName: b.name,
assigned: b.newProjects.length,
skipped: b.skippedProjects.length,
})),
emailsSent: Array.from(perMentor.values()).filter(
(b) => b.newProjects.length > 0 && b.email,
).length,
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
}
}),
/**
* Remove mentor assignment.
*
* Multi-mentor (PR8): callers should pass `assignmentId` to target a
* specific co-mentor. Legacy callers passing only `projectId` get the
* most-recent assignment removed (kept for backward compatibility).
*/
unassign: adminProcedure
.input(
z
.object({
assignmentId: z.string().optional(),
projectId: z.string().optional(),
})
.refine((v) => !!v.assignmentId || !!v.projectId, {
message: 'Either assignmentId or projectId is required',
}),
)
.mutation(async ({ ctx, input }) => {
const assignment = input.assignmentId
? await ctx.prisma.mentorAssignment.findUnique({
where: { id: input.assignmentId },
include: {
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } },
},
})
: await ctx.prisma.mentorAssignment.findFirst({
where: { projectId: input.projectId! },
orderBy: { assignedAt: 'desc' },
include: {
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } },
},
})
if (!assignment) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No mentor assignment found',
})
}
// Delete assignment
await ctx.prisma.mentorAssignment.delete({
where: { id: assignment.id },
})
// Audit outside transaction so failures don't roll back the unassignment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: assignment.project.id,
projectTitle: assignment.project.title,
mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
/**
* Bulk auto-assign mentors to projects without one
*/
bulkAutoAssign: adminProcedure
.input(
z.object({
programId: z.string(),
useAI: z.boolean().default(true),
maxAssignments: z.number().min(1).max(100).default(50),
})
)
.mutation(async ({ ctx, input }) => {
// Get projects without mentors
const projects = await ctx.prisma.project.findMany({
where: {
programId: input.programId,
mentorAssignments: { none: {} },
wantsMentorship: true,
},
select: { id: true },
take: input.maxAssignments,
})
if (projects.length === 0) {
return {
assigned: 0,
failed: 0,
message: 'No projects need mentor assignment',
}
}
let assigned = 0
let failed = 0
for (const project of projects) {
try {
let mentorId: string | null = null
let method: MentorAssignmentMethod = 'ALGORITHM'
let aiConfidenceScore: number | undefined
let expertiseMatchScore: number | undefined
let aiReasoning: string | undefined
if (input.useAI) {
const suggestions = await getAIMentorSuggestions(ctx.prisma, project.id, 1)
if (suggestions.length > 0) {
const best = suggestions[0]
mentorId = best.mentorId
method = 'AI_AUTO'
aiConfidenceScore = best.confidenceScore
expertiseMatchScore = best.expertiseMatchScore
aiReasoning = best.reasoning
}
}
if (!mentorId) {
mentorId = await getRoundRobinMentor(ctx.prisma)
method = 'ALGORITHM'
}
if (mentorId) {
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId,
method,
assignedBy: ctx.user.id,
aiConfidenceScore,
expertiseMatchScore,
aiReasoning,
},
include: {
mentor: { select: { name: true } },
project: { select: { title: true } },
},
})
// Get team lead info
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: project.id, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor
await createNotification({
userId: mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team
await notifyProjectTeam(project.id, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
assigned++
} else {
failed++
}
} catch (err) {
console.error('Failed to send mentor assignment notifications:', err)
failed++
}
}
// Create audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Program',
entityId: input.programId,
detailsJson: {
assigned,
failed,
useAI: input.useAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
assigned,
failed,
message: `Assigned ${assigned} mentor(s), ${failed} failed`,
}
}),
/**
* Round-scoped bulk auto-assign. Filters to projects in the round without a
* mentor, further scoped by configJson.eligibility:
* - requested_only: project.wantsMentorship === true
* - all_advancing: every project in the round
* - admin_selected: refuses (admin must pick manually)
*/
autoAssignBulkForRound: adminProcedure
.input(
z.object({
roundId: z.string(),
useAI: z.boolean().default(true),
maxAssignments: z.number().min(1).max(200).default(100),
}),
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, roundType: true, configJson: true },
})
if (round.roundType !== 'MENTORING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round is not a MENTORING round',
})
}
const config = (round.configJson ?? {}) as Record<string, unknown>
const eligibility = (config.eligibility as string) ?? 'requested_only'
if (eligibility === 'admin_selected') {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'Round eligibility is admin_selected — assign each project manually.',
})
}
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: {
roundId: input.roundId,
project: {
fix(mentor): unbreak the mentor pipeline end-to-end Adding the MENTOR role from /admin/members/[id] only updated React state — the AlertDialog "Add role" confirmation never called the server, so prod ended up with zero users in MENTOR roles[] and /admin/mentors showed "No mentors yet". The dialog now awaits updateUser.mutateAsync({ roles }) before closing. Other corrections in the same area: - DialogContent uses flex flex-col with max-h-[90vh] overflow-y-auto so tall modals (e.g. Add Project to Round) scroll internally instead of overflowing past their own rounded background. - getProjectsNeedingMentor now matches autoAssignBulkForRound exactly: both filter mentorAssignments by droppedAt: null and require finalistConfirmation: CONFIRMED, so the toolbar count never exceeds what auto-fill actually processes. The toolbar surfaces hasNoMentors / hasNoEligible / count / all-assigned as distinct states instead of one misleading "All eligible projects have a mentor" line. - New per-team table (MentoringProjectsTable) replaces ProjectStatesTable on the Projects tab of MENTORING rounds. Lists every project with its active mentors (multi-mentor aware), filter pills, search, finalist-confirmation badge, and a per-row link to /admin/projects/[id]/mentor for assigning. - Applicant team page now lists ALL active mentors (PR8 Task 7) instead of just mentorAssignments[0]. - Hard guard in src/lib/email.ts short-circuits sendEmail when NODE_ENV=test or VITEST=true so test runs can never emit real notifications again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:01:05 +02:00
mentorAssignments: { none: { droppedAt: null } },
// Only assign mentors to projects whose team has confirmed they will
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
// confirmations and any project without a confirmation row at all.
finalistConfirmation: { status: 'CONFIRMED' },
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
},
},
select: { project: { select: { id: true, title: true } } },
take: input.maxAssignments,
})
if (projectStates.length === 0) {
return {
assigned: 0,
skipped: 0,
unassignable: 0,
message: 'No projects need a mentor.',
}
}
let assigned = 0
let unassignable = 0
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
// Coalesce per-mentor so we send ONE email per mentor at the end of the
// batch, even when the algorithm assigns the same mentor to several teams.
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
projects: { id: string; title: string }[]
}
>()
for (const { project } of projectStates) {
try {
let mentorId: string | null = null
let method: MentorAssignmentMethod = 'ALGORITHM'
let aiConfidenceScore: number | undefined
let expertiseMatchScore: number | undefined
let aiReasoning: string | undefined
if (input.useAI) {
const suggestions = await getAIMentorSuggestions(ctx.prisma, project.id, 1)
if (suggestions.length > 0) {
const best = suggestions[0]
mentorId = best.mentorId
method = 'AI_AUTO'
aiConfidenceScore = best.confidenceScore
expertiseMatchScore = best.expertiseMatchScore
aiReasoning = best.reasoning
}
}
if (!mentorId) {
mentorId = await getRoundRobinMentor(ctx.prisma)
method = 'ALGORITHM'
}
if (!mentorId) {
unassignable++
continue
}
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId,
method,
assignedBy: ctx.user.id,
aiConfidenceScore,
expertiseMatchScore,
aiReasoning,
},
include: {
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
mentor: { select: { id: true, name: true, email: true } },
project: { select: { title: true } },
},
})
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: project.id, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
await createNotification({
userId: mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
await notifyProjectTeam(project.id, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
// Accumulate for the coalesced email
const bucket = perMentor.get(mentorId) ?? {
email: assignment.mentor.email ?? null,
name: assignment.mentor.name ?? null,
assignmentIds: [],
projects: [],
}
bucket.assignmentIds.push(assignment.id)
bucket.projects.push({ id: project.id, title: assignment.project.title })
perMentor.set(mentorId, bucket)
assigned++
} catch (err) {
console.error(
'[Mentor] autoAssignBulkForRound failure for project',
project.id,
err,
)
unassignable++
}
}
// Defer all emails when the round is still ROUND_DRAFT — activateRound
// will coalesce and send them when the admin opens the round. Stamp
// notificationSentAt only for assignments we actually email about, so
// activateRound's `notificationSentAt IS NULL` filter catches the rest.
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
const roundStatus = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { status: true },
})
const isRoundLive = roundStatus?.status === 'ROUND_ACTIVE'
if (isRoundLive) {
for (const bucket of perMentor.values()) {
if (!bucket.email || bucket.projects.length === 0) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.projects,
)
try {
await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } },
data: { notificationSentAt: new Date() },
})
} catch (e) {
console.error(
'[Mentor.autoAssignBulkForRound] failed to stamp notificationSentAt (non-fatal):',
e,
)
}
}
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
const introducedProjects = new Set<string>()
for (const bucket of perMentor.values()) {
for (const p of bucket.projects) {
if (introducedProjects.has(p.id)) continue
introducedProjects.add(p.id)
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
}
}
}
// If the round is still ROUND_DRAFT, no emails fire here — the assignments
// remain unstamped and activateRound will batch-send when the round opens.
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
const skipped = await ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: {
mentorAssignments: { some: {} },
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
},
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
eligibility,
assigned,
unassignable,
skipped,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
assigned,
skipped: Math.max(0, skipped - assigned),
unassignable,
message: `Assigned ${assigned} mentor(s), ${Math.max(0, skipped - assigned)} already assigned, ${unassignable} unassignable.`,
}
}),
/**
* MENTORING-round stats card: totals + request window + workspace activity.
* Single round-scoped query set; cheap enough to call uncached.
*/
getRoundStats: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
id: true,
roundType: true,
configJson: true,
windowOpenAt: true,
},
})
if (round.roundType !== 'MENTORING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round is not a MENTORING round',
})
}
const [
totalProjects,
requestedCount,
assignedAndRequested,
totalAssigned,
messageCount,
fileCount,
milestoneCount,
latestMessage,
latestFile,
latestMilestone,
] = await Promise.all([
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
ctx.prisma.projectRoundState.count({
where: { roundId: input.roundId, project: { wantsMentorship: true } },
}),
ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: { wantsMentorship: true, mentorAssignments: { some: {} } },
},
}),
ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: { mentorAssignments: { some: {} } },
},
}),
ctx.prisma.mentorMessage.count({
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
}),
ctx.prisma.mentorFile.count({
where: {
mentorAssignment: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
},
}),
ctx.prisma.mentorMilestoneCompletion.count({
where: {
mentorAssignment: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
},
}),
ctx.prisma.mentorMessage.findFirst({
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
}),
ctx.prisma.mentorFile.findFirst({
where: {
mentorAssignment: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
},
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
}),
ctx.prisma.mentorMilestoneCompletion.findFirst({
where: {
mentorAssignment: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
},
orderBy: { completedAt: 'desc' },
select: { completedAt: true },
}),
])
const config = (round.configJson ?? {}) as Record<string, unknown>
const deadlineDays =
typeof config.mentoringRequestDeadlineDays === 'number'
? config.mentoringRequestDeadlineDays
: 14
const deadline = round.windowOpenAt
? new Date(round.windowOpenAt.getTime() + deadlineDays * 86_400_000)
: null
const lastActivityAt =
[latestMessage?.createdAt, latestFile?.createdAt, latestMilestone?.completedAt]
.filter((d): d is Date => Boolean(d))
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null
return {
totalProjects,
requestedCount,
assignedCount: totalAssigned,
awaitingAssignment: Math.max(0, requestedCount - assignedAndRequested),
requestWindow: {
deadline,
deadlineDays,
},
workspaceActivity: {
messageCount,
fileCount,
milestoneCount,
lastActivityAt,
},
}
}),
/**
* All MENTOR-role users with current/completed assignment counts, capacity,
* country, expertise, and last activity. Drives the /admin/mentors list page
* and the round-overview pool card.
*/
getMentorPool: adminProcedure
.input(z.object({ programId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const mentors = await ctx.prisma.user.findMany({
where: {
roles: { has: 'MENTOR' },
status: { not: 'SUSPENDED' },
},
select: {
id: true,
name: true,
email: true,
country: true,
expertiseTags: true,
maxAssignments: true,
mentorAssignments: {
where: {
droppedAt: null,
...(input.programId ? { project: { programId: input.programId } } : {}),
},
select: {
completionStatus: true,
project: { select: { id: true, title: true } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
files: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
milestoneCompletions: {
orderBy: { completedAt: 'desc' },
take: 1,
select: { completedAt: true },
},
},
},
},
orderBy: { name: 'asc' },
})
let totalCurrentAssignments = 0
const enriched = mentors.map((m) => {
let current = 0
let completed = 0
const activityDates: Date[] = []
const activeTeams: { id: string; title: string }[] = []
for (const a of m.mentorAssignments) {
if (a.completionStatus === 'completed') {
completed++
} else {
current++
activeTeams.push(a.project)
}
if (a.messages[0]) activityDates.push(a.messages[0].createdAt)
if (a.files[0]) activityDates.push(a.files[0].createdAt)
if (a.milestoneCompletions[0])
activityDates.push(a.milestoneCompletions[0].completedAt)
}
totalCurrentAssignments += current
const lastActivityAt =
activityDates.length > 0
? activityDates.sort((a, b) => b.getTime() - a.getTime())[0]
: null
const capacityRemaining =
m.maxAssignments != null ? Math.max(0, m.maxAssignments - current) : null
return {
id: m.id,
name: m.name,
email: m.email,
country: m.country,
expertiseTags: m.expertiseTags,
currentAssignments: current,
completedAssignments: completed,
maxAssignments: m.maxAssignments,
capacityRemaining,
lastActivityAt,
activeTeams,
}
})
return {
mentors: enriched,
poolSize: enriched.length,
totalCurrentAssignments,
}
}),
/**
* 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,
mentorAssignments: {
// TODO(PR8 Task 8): surface all mentors in the activity view.
// For now keep the legacy single-mentor activity row by picking the
// latest-assigned, non-dropped assignment (or the most-recent overall).
orderBy: { assignedAt: 'desc' },
select: {
id: true,
method: true,
assignedAt: true,
completionStatus: true,
droppedAt: true,
mentor: {
select: {
id: true,
name: true,
email: true,
maxAssignments: true,
_count: {
select: {
mentorAssignments: { where: { droppedAt: null } },
},
},
},
},
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) => {
// Treat a dropped mentor assignment as if no mentor is assigned.
// TODO(PR8 Task 8): surface all mentors. Legacy shape: pick the most
// recent non-dropped assignment for the activity row.
const firstActive = p.mentorAssignments.find((a) => !a.droppedAt) ?? null
const ma = firstActive
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
*/
getMyProjects: mentorProcedure.query(async ({ ctx }) => {
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: ctx.user.id, droppedAt: null },
include: {
project: {
include: {
program: { select: { id: true, name: true, year: true } },
teamMembers: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
},
},
orderBy: { assignedAt: 'desc' },
})
return assignments
}),
/**
* List all active mentors assigned to a project (PR8 multi-mentor).
*
* Returns one row per active MentorAssignment (droppedAt = null) with the
* mentor's id + name. Used by the mentor workspace page to display the
* co-mentor team so each mentor knows who else they're working with.
*
* Authorization: caller must be an active mentor on the project (or an
* admin via mentorProcedure). Non-assigned mentors get FORBIDDEN.
*/
getProjectMentors: mentorProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const ownAssignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
droppedAt: null,
},
select: { id: true },
})
if (!ownAssignment) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
}
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { projectId: input.projectId, droppedAt: null },
select: {
id: true,
mentor: { select: { id: true, name: true } },
},
orderBy: { assignedAt: 'asc' },
})
return assignments
}),
/**
* Get detailed project info for a mentor's assigned project
*/
getProjectDetail: mentorProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
// Allow admins to access any project
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
program: { select: { id: true, name: true, year: true } },
teamMembers: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
},
},
},
orderBy: { role: 'asc' },
},
files: {
orderBy: { createdAt: 'desc' },
},
mentorAssignments: {
include: {
mentor: {
select: { id: true, name: true, email: true },
},
},
},
},
})
return {
...project,
assignedAt: assignment?.assignedAt,
}
}),
/**
* Send a message to the project team (mentor side)
*/
sendMessage: mentorProcedure
.input(
z.object({
projectId: z.string(),
message: z.string().min(1).max(5000),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const mentorMessage = await ctx.prisma.mentorMessage.create({
data: {
projectId: input.projectId,
senderId: ctx.user.id,
message: input.message,
},
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
})
// Notify project team members
await notifyProjectTeam(input.projectId, {
type: 'MENTOR_MESSAGE',
title: 'New Message from Mentor',
message: `${ctx.user.name || 'Your mentor'} sent you a message`,
linkUrl: `/applicant/mentor`,
linkLabel: 'View Message',
priority: 'normal',
metadata: {
projectId: input.projectId,
},
})
return mentorMessage
}),
/**
* Get messages for a project (mentor side)
*/
getMessages: mentorProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const messages = await ctx.prisma.mentorMessage.findMany({
where: { projectId: input.projectId },
include: {
sender: {
select: { id: true, name: true, email: true, role: true },
},
},
orderBy: { createdAt: 'asc' },
})
// Mark unread messages from the team as read
await ctx.prisma.mentorMessage.updateMany({
where: {
projectId: input.projectId,
senderId: { not: ctx.user.id },
isRead: false,
},
data: { isRead: true },
})
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)
*/
listAssignments: adminProcedure
.input(
z.object({
programId: z.string().optional(),
mentorId: z.string().optional(),
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where = {
...(input.programId && { project: { programId: input.programId } }),
...(input.mentorId && { mentorId: input.mentorId }),
}
const [assignments, total] = await Promise.all([
ctx.prisma.mentorAssignment.findMany({
where,
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
oceanIssue: true,
competitionCategory: true,
status: true,
},
},
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
},
orderBy: { assignedAt: 'desc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.mentorAssignment.count({ where }),
])
return {
assignments,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
// =========================================================================
// Mentor Notes CRUD (F8)
// =========================================================================
/**
* Create a mentor note for an assignment
*/
createNote: mentorProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
content: z.string().min(1).max(10000),
isVisibleToAdmin: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user owns this assignment or is admin
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true, projectId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
const note = await ctx.prisma.mentorNote.create({
data: {
mentorAssignmentId: input.mentorAssignmentId,
authorId: ctx.user.id,
content: input.content,
isVisibleToAdmin: input.isVisibleToAdmin,
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_MENTOR_NOTE',
entityType: 'MentorNote',
entityId: note.id,
detailsJson: {
mentorAssignmentId: input.mentorAssignmentId,
projectId: assignment.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch (err) {
console.error('[Mentor] Audit log failed:', err)
}
return note
}),
/**
* Update a mentor note
*/
updateNote: mentorProcedure
.input(
z.object({
noteId: z.string(),
content: z.string().min(1).max(10000),
isVisibleToAdmin: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
where: { id: input.noteId },
select: { authorId: true },
})
if (note.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only edit your own notes',
})
}
return ctx.prisma.mentorNote.update({
where: { id: input.noteId },
data: {
content: input.content,
...(input.isVisibleToAdmin !== undefined && { isVisibleToAdmin: input.isVisibleToAdmin }),
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
}),
/**
* Delete a mentor note
*/
deleteNote: mentorProcedure
.input(z.object({ noteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
where: { id: input.noteId },
select: { authorId: true },
})
if (note.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only delete your own notes',
})
}
return ctx.prisma.mentorNote.delete({
where: { id: input.noteId },
})
}),
/**
* Get notes for a mentor assignment
*/
getNotes: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
// Admins see all notes; mentors see only their own
const where: Record<string, unknown> = { mentorAssignmentId: input.mentorAssignmentId }
if (!isAdmin) {
where.authorId = ctx.user.id
}
return ctx.prisma.mentorNote.findMany({
where,
include: {
author: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'desc' },
})
}),
// =========================================================================
// Milestone Operations (F8)
// =========================================================================
/**
* Get milestones for a program with completion status
*/
getMilestones: mentorProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const milestones = await ctx.prisma.mentorMilestone.findMany({
where: { programId: input.programId },
include: {
completions: {
include: {
mentorAssignment: { select: { id: true, projectId: true } },
},
},
},
orderBy: { sortOrder: 'asc' },
})
// Get current user's assignments for completion status context
const myAssignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: ctx.user.id },
select: { id: true, projectId: true },
})
const myAssignmentIds = new Set(myAssignments.map((a) => a.id))
return milestones.map((milestone: typeof milestones[number]) => ({
...milestone,
myCompletions: milestone.completions.filter((c: { mentorAssignmentId: string }) =>
myAssignmentIds.has(c.mentorAssignmentId)
),
}))
}),
/**
* Mark a milestone as completed for an assignment
*/
completeMilestone: mentorProcedure
.input(
z.object({
milestoneId: z.string(),
mentorAssignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user owns this assignment
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true, projectId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
const completion = await ctx.prisma.mentorMilestoneCompletion.create({
data: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
completedById: ctx.user.id,
},
})
// Check if all required milestones are now completed
const milestone = await ctx.prisma.mentorMilestone.findUniqueOrThrow({
where: { id: input.milestoneId },
select: { programId: true },
})
const requiredMilestones = await ctx.prisma.mentorMilestone.findMany({
where: { programId: milestone.programId, isRequired: true },
select: { id: true },
})
const completedMilestones = await ctx.prisma.mentorMilestoneCompletion.findMany({
where: {
mentorAssignmentId: input.mentorAssignmentId,
milestoneId: { in: requiredMilestones.map((m: { id: string }) => m.id) },
},
select: { milestoneId: true },
})
const allRequiredDone = requiredMilestones.length > 0 &&
completedMilestones.length >= requiredMilestones.length
if (allRequiredDone) {
await ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { completionStatus: 'completed' },
})
}
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COMPLETE_MILESTONE',
entityType: 'MentorMilestoneCompletion',
entityId: completion.id,
detailsJson: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
allRequiredDone,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch (err) {
console.error('[Mentor] Audit log failed:', err)
}
return { completion, allRequiredDone }
}),
/**
* Uncomplete a milestone for an assignment
*/
uncompleteMilestone: mentorProcedure
.input(
z.object({
milestoneId: z.string(),
mentorAssignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
await ctx.prisma.mentorMilestoneCompletion.delete({
where: {
milestoneId_mentorAssignmentId: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
},
},
})
// Revert completion status if it was completed
await ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { completionStatus: 'in_progress' },
})
return { success: true }
}),
// =========================================================================
// Admin Milestone Management (F8)
// =========================================================================
/**
* Create a milestone for a program
*/
createMilestone: adminProcedure
.input(
z.object({
programId: z.string(),
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
isRequired: z.boolean().default(false),
deadlineOffsetDays: z.number().int().optional().nullable(),
sortOrder: z.number().int().default(0),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.mentorMilestone.create({
data: input,
})
}),
/**
* Update a milestone
*/
updateMilestone: adminProcedure
.input(
z.object({
milestoneId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional().nullable(),
isRequired: z.boolean().optional(),
deadlineOffsetDays: z.number().int().optional().nullable(),
sortOrder: z.number().int().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { milestoneId, ...data } = input
return ctx.prisma.mentorMilestone.update({
where: { id: milestoneId },
data,
})
}),
/**
* Delete a milestone (cascades completions)
*/
deleteMilestone: adminProcedure
.input(z.object({ milestoneId: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.mentorMilestone.delete({
where: { id: input.milestoneId },
})
}),
/**
* Reorder milestones
*/
reorderMilestones: adminProcedure
.input(
z.object({
milestoneIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.milestoneIds.map((id, index) =>
ctx.prisma.mentorMilestone.update({
where: { id },
data: { sortOrder: index },
})
)
)
return { success: true }
}),
// =========================================================================
// Activity Tracking (F8)
// =========================================================================
/**
* Track a mentor's view of an assignment
*/
trackView: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
return ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { lastViewedAt: new Date() },
})
}),
/**
* Get activity stats for all mentors (admin)
*/
getActivityStats: adminProcedure
.input(
z.object({
programId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.programId
? { project: { programId: input.programId } }
: {}
const assignments = await ctx.prisma.mentorAssignment.findMany({
where,
include: {
mentor: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
notes: { select: { id: true } },
milestoneCompletions: { select: { milestoneId: true } },
},
})
// Get message counts per mentor
const mentorIds = [...new Set(assignments.map((a) => a.mentorId))]
const messageCounts = await ctx.prisma.mentorMessage.groupBy({
by: ['senderId'],
where: { senderId: { in: mentorIds } },
_count: true,
})
const messageCountMap = new Map(messageCounts.map((m) => [m.senderId, m._count]))
// Build per-mentor stats
const mentorStats = new Map<string, {
mentor: { id: string; name: string | null; email: string }
assignments: number
lastViewedAt: Date | null
notesCount: number
milestonesCompleted: number
messagesSent: number
completionStatuses: string[]
}>()
for (const assignment of assignments) {
const existing = mentorStats.get(assignment.mentorId)
if (existing) {
existing.assignments++
existing.notesCount += assignment.notes.length
existing.milestonesCompleted += assignment.milestoneCompletions.length
existing.completionStatuses.push(assignment.completionStatus)
if (assignment.lastViewedAt && (!existing.lastViewedAt || assignment.lastViewedAt > existing.lastViewedAt)) {
existing.lastViewedAt = assignment.lastViewedAt
}
} else {
mentorStats.set(assignment.mentorId, {
mentor: assignment.mentor,
assignments: 1,
lastViewedAt: assignment.lastViewedAt,
notesCount: assignment.notes.length,
milestonesCompleted: assignment.milestoneCompletions.length,
messagesSent: messageCountMap.get(assignment.mentorId) || 0,
completionStatuses: [assignment.completionStatus],
})
}
}
return Array.from(mentorStats.values())
}),
// =========================================================================
// Workspace Procedures (Phase 4)
// =========================================================================
/**
* Activate a mentor workspace
*/
activateWorkspace: adminProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await activateWorkspace(input.mentorAssignmentId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to activate workspace',
})
}
return result
}),
/**
* Send a message in a mentor workspace
*/
workspaceSendMessage: protectedProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
message: z.string().min(1).max(5000),
role: z.enum(['MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE']),
})
)
.mutation(async ({ ctx, input }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
return workspaceSendMessage(
{
workspaceId: input.mentorAssignmentId,
senderId: ctx.user.id,
message: input.message,
role: input.role,
},
ctx.prisma,
)
}),
/**
* Get workspace messages
*/
workspaceGetMessages: protectedProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
return workspaceGetMessages(input.mentorAssignmentId, ctx.prisma)
}),
/**
* Mark a workspace message as read.
* Resolves the message to its workspace and verifies caller belongs to it.
* Caller must also not be the sender (you only mark someone else's message
* as read, not your own keeps unread state honest).
*/
workspaceMarkRead: protectedProcedure
.input(z.object({ messageId: z.string() }))
.mutation(async ({ ctx, input }) => {
const message = await ctx.prisma.mentorMessage.findUnique({
where: { id: input.messageId },
select: { workspaceId: true, senderId: true },
})
if (!message || !message.workspaceId) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Message not found' })
}
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, message.workspaceId)
if (message.senderId === ctx.user.id) {
// Senders can't mark their own messages as read by others.
return { success: true }
}
await workspaceMarkRead(input.messageId, ctx.prisma)
return { success: true }
}),
/**
* Issue a presigned upload URL + signed token for a mentor-workspace file.
* The token binds the bucket, objectKey, and uploader so the client cannot
* forge a path; workspaceUploadFile reads the token, never the
* client-supplied path.
*/
workspaceGetUploadUrl: protectedProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
fileName: z.string().min(1).max(255),
mimeType: z.string().min(1).max(200),
size: z.number().int().min(0).max(500 * 1024 * 1024),
})
)
.mutation(async ({ ctx, input }) => {
const assignment = await assertWorkspaceAccess(
ctx.prisma, ctx.user.id, input.mentorAssignmentId,
)
const objectKey = generateMentorObjectKey(assignment.project.title, input.fileName)
const uploadUrl = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600)
const exp = Math.floor(Date.now() / 1000) + 3600
const uploadToken = signMentorUploadToken({
mentorAssignmentId: assignment.id,
projectId: assignment.projectId,
uploaderUserId: ctx.user.id,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
bucket: BUCKET_NAME,
objectKey,
exp,
})
return { uploadUrl, uploadToken, bucket: BUCKET_NAME, objectKey }
}),
/**
* Record a workspace file upload. Requires a valid uploadToken issued by
* workspaceGetUploadUrl the token contains the server-built bucket,
* objectKey, and uploader binding. The client cannot pass a path directly.
*/
workspaceUploadFile: protectedProcedure
.input(
z.object({
uploadToken: z.string(),
description: z.string().max(2000).optional(),
})
)
.mutation(async ({ ctx, input }) => {
let payload
try {
payload = verifyMentorUploadToken(input.uploadToken)
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: e instanceof Error ? e.message : 'Invalid upload token',
})
}
if (payload.uploaderUserId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Upload token does not belong to the current user',
})
}
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, payload.mentorAssignmentId)
return workspaceUploadFile(
{
workspaceId: payload.mentorAssignmentId,
uploadedByUserId: ctx.user.id,
fileName: payload.fileName,
mimeType: payload.mimeType,
size: payload.size,
bucket: payload.bucket,
objectKey: payload.objectKey,
description: input.description,
},
ctx.prisma,
)
}),
/**
* List files in a project's mentor workspace. Authorized for any mentor
* currently assigned to the project, or any team member of the project.
*
* Project-scoped (PR8): all co-mentors share one file list, and files
* survive even when an originating assignment is later dropped.
*/
workspaceGetFiles: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
return workspaceGetFilesService(input.projectId, ctx.prisma)
}),
/**
* Issue a short-lived presigned GET URL to download a workspace file.
*/
workspaceGetFileDownloadUrl: protectedProcedure
.input(z.object({
mentorFileId: z.string(),
disposition: z.enum(['inline', 'attachment']).default('attachment'),
}))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { bucket: true, objectKey: true, fileName: true, mimeType: true, projectId: true },
})
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
input.disposition === 'inline'
? { inline: true, contentType: file.mimeType }
: { downloadFileName: file.fileName })
return { url }
}),
/**
* Delete a workspace file. Authorized for the uploader, any mentor
* currently assigned to the file's project, or any team member of the
* file's project. Final auth check lives in the service.
*/
workspaceDeleteFile: protectedProcedure
.input(z.object({ mentorFileId: z.string() }))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { projectId: true },
})
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
try {
await workspaceDeleteFileService(
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
ctx.prisma,
deleteObject,
)
} catch (e) {
throw new TRPCError({
code: 'FORBIDDEN',
message: e instanceof Error ? e.message : 'Delete failed',
})
}
return { success: true }
}),
/**
* Add a comment to a workspace file
*/
workspaceAddFileComment: protectedProcedure
.input(
z.object({
mentorFileId: z.string(),
content: z.string().min(1).max(5000),
parentCommentId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { projectId: true },
})
if (!file) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
}
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
return workspaceAddFileComment(
{
mentorFileId: input.mentorFileId,
authorId: ctx.user.id,
content: input.content,
parentCommentId: input.parentCommentId,
},
ctx.prisma,
)
}),
/**
* Promote a workspace file to official submission
*/
workspacePromoteFile: adminProcedure
.input(
z.object({
mentorFileId: z.string(),
roundId: z.string(),
slotKey: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const result = await workspacePromoteFile(
{
mentorFileId: input.mentorFileId,
roundId: input.roundId,
slotKey: input.slotKey,
promotedById: ctx.user.id,
},
ctx.prisma,
)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to promote file',
})
}
return result
}),
/**
* Mentor self-drops an assignment with a required reason. Notifies all
* program admins so they can re-assign. Audit-logged.
*/
dropAssignment: mentorProcedure
.input(
z.object({
assignmentId: z.string(),
reason: z
.string()
.min(10, 'Reason must be at least 10 characters')
.max(500),
}),
)
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: {
project: { select: { id: true, title: true } },
},
})
if (assignment.mentorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This is not your assignment',
})
}
if (assignment.droppedAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Assignment is already dropped',
})
}
if (assignment.completionStatus === 'completed') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Assignment is already completed',
})
}
const dropped = await ctx.prisma.mentorAssignment.update({
where: { id: assignment.id },
data: {
droppedAt: new Date(),
droppedReason: input.reason,
droppedBy: 'mentor',
},
})
// Notify program admins (best-effort, never block the drop)
try {
const admins = await ctx.prisma.user.findMany({
where: {
roles: { hasSome: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
status: { not: 'SUSPENDED' },
},
select: { id: true },
})
const mentorName = ctx.user.name ?? ctx.user.email
for (const admin of admins) {
await createNotification({
userId: admin.id,
type: NotificationTypes.MENTOR_DROPPED,
title: 'Mentor dropped a team',
message: `${mentorName} dropped their mentee "${assignment.project.title}". Reason: ${input.reason}`,
linkUrl: `/admin/projects/${assignment.project.id}/mentor`,
priority: 'high',
})
}
} catch (err) {
console.error('[mentor.dropAssignment] notify admins failed:', err)
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_DROP_ASSIGNMENT',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
reason: input.reason,
projectId: assignment.project.id,
},
})
return dropped
}),
/**
* Per-mentor activity detail used by the admin mentor side sheet. For each
* (active or dropped) assignment, returns the project, key timestamps, and
* counts of messages, files, and completed milestones so the admin can see
* "what has this mentor been up to" at a glance.
*/
getMentorDetail: adminProcedure
.input(z.object({ mentorId: z.string() }))
.query(async ({ ctx, input }) => {
const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId },
select: {
id: true,
name: true,
email: true,
country: true,
expertiseTags: true,
maxAssignments: true,
createdAt: true,
},
})
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: input.mentorId },
orderBy: { assignedAt: 'desc' },
include: {
project: {
select: {
id: true,
title: true,
competitionCategory: true,
country: true,
},
},
_count: {
select: { messages: true, files: true, milestoneCompletions: true },
},
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
files: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
milestoneCompletions: {
orderBy: { completedAt: 'desc' },
take: 1,
select: { completedAt: true },
},
},
})
return {
mentor,
assignments: assignments.map((a) => ({
id: a.id,
assignedAt: a.assignedAt,
method: a.method,
completionStatus: a.completionStatus,
droppedAt: a.droppedAt,
droppedReason: a.droppedReason,
droppedBy: a.droppedBy,
workspaceEnabled: a.workspaceEnabled,
project: a.project,
messageCount: a._count.messages,
fileCount: a._count.files,
milestoneCompletionCount: a._count.milestoneCompletions,
lastMessageAt: a.messages[0]?.createdAt ?? null,
lastFileAt: a.files[0]?.createdAt ?? null,
lastMilestoneAt: a.milestoneCompletions[0]?.completedAt ?? null,
})),
}
}),
// ===========================================================================
// Mentor change requests (PR8)
//
// Applicants (team members) or admins can open a PENDING change request for
// a project — optionally targeting a specific co-mentor assignment. Admins
// are notified by email; mentors are intentionally NOT notified, even after
// resolution (per design decision in the PR8 plan).
// ===========================================================================
/**
* Open a new mentor change request. Allowed for:
* SUPER_ADMIN / PROGRAM_ADMIN (any project), or
* a team member of the target project.
*
* Rejects with CONFLICT if the same user already has an open (PENDING) request
* for the same project. The raw `reason` is intentionally NOT included in
* audit logs only its length for privacy. Email delivery to admins is
* best-effort and never throws.
*/
requestChange: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
targetAssignmentId: z.string().min(1).optional(),
reason: z.string().min(10).max(2000),
}),
)
.mutation(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
// Authorization: admin OR team member of the project
if (!isAdmin) {
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: 'You are not a member of this project',
})
}
}
// Load project (also confirms it exists) and validate optional target
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, title: true },
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
if (input.targetAssignmentId) {
const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({
where: { id: input.targetAssignmentId },
select: { id: true, projectId: true },
})
if (!targetAssignment || targetAssignment.projectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Target assignment does not belong to this project',
})
}
}
// One open request per (user, project)
const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({
where: {
projectId: input.projectId,
requestedByUserId: ctx.user.id,
status: MentorChangeRequestStatus.PENDING,
},
select: { id: true },
})
if (existingOpen) {
throw new TRPCError({
code: 'CONFLICT',
message: 'You already have an open mentor change request for this project.',
})
}
const created = await ctx.prisma.mentorChangeRequest.create({
data: {
projectId: input.projectId,
targetAssignmentId: input.targetAssignmentId ?? null,
requestedByUserId: ctx.user.id,
reason: input.reason,
status: MentorChangeRequestStatus.PENDING,
},
select: { id: true, status: true, createdAt: true },
})
// Notify admins (best-effort, never throw)
try {
const admins = await ctx.prisma.user.findMany({
where: {
OR: [
{ roles: { has: 'SUPER_ADMIN' } },
{ roles: { has: 'PROGRAM_ADMIN' } },
],
status: 'ACTIVE',
},
select: { email: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor`
await sendMentorChangeRequestEmail(
admins.map((a) => a.email),
project.title,
ctx.user.name ?? null,
input.reason,
adminDashboardUrl,
)
} catch (err) {
// Defense-in-depth: the helper already has its own try/catch
console.error('[mentor.requestChange] notify admins failed:', err)
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_CHANGE_REQUEST_CREATE',
entityType: 'MentorChangeRequest',
entityId: created.id,
detailsJson: {
projectId: input.projectId,
targetAssignmentId: input.targetAssignmentId ?? null,
reasonLength: input.reason.length,
},
})
return created
}),
/**
* Admin inbox list MentorChangeRequest rows, optionally filtered by status
* and/or project. PENDING rows are surfaced first; within each status group
* rows are ordered by createdAt desc. No pagination (low-volume admin view).
*/
listChangeRequests: adminProcedure
.input(
z
.object({
status: z.nativeEnum(MentorChangeRequestStatus).optional(),
projectId: z.string().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const where: Prisma.MentorChangeRequestWhereInput = {}
if (input?.status) where.status = input.status
if (input?.projectId) where.projectId = input.projectId
const rows = await ctx.prisma.mentorChangeRequest.findMany({
where,
include: {
project: { select: { id: true, title: true } },
targetAssignment: {
select: {
id: true,
mentor: { select: { id: true, name: true, email: true } },
},
},
requestedBy: { select: { id: true, name: true, email: true } },
resolvedBy: { select: { id: true, name: true, email: true } },
},
// PENDING first, then RESOLVED/DISMISSED. Within each: newest first.
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
})
// Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED
// is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING
// appears first regardless of enum string ordering.
const statusRank: Record<MentorChangeRequestStatus, number> = {
[MentorChangeRequestStatus.PENDING]: 0,
[MentorChangeRequestStatus.RESOLVED]: 1,
[MentorChangeRequestStatus.DISMISSED]: 2,
}
return rows.sort((a, b) => {
const sa = statusRank[a.status] - statusRank[b.status]
if (sa !== 0) return sa
return b.createdAt.getTime() - a.createdAt.getTime()
})
}),
/**
* Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution
* is rejected. No email or notification is sent to the requester or mentors
* (per PR8 design decision mentors are never informed of change requests).
*/
resolveChangeRequest: adminProcedure
.input(
z.object({
id: z.string().min(1),
status: z.enum(['RESOLVED', 'DISMISSED']),
resolutionNote: z.string().max(2000).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.mentorChangeRequest.findUnique({
where: { id: input.id },
select: { id: true, status: true, projectId: true },
})
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' })
}
if (existing.status !== MentorChangeRequestStatus.PENDING) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Request already resolved',
})
}
const updated = await ctx.prisma.mentorChangeRequest.update({
where: { id: existing.id },
data: {
status: input.status as MentorChangeRequestStatus,
resolvedByUserId: ctx.user.id,
resolvedAt: new Date(),
resolutionNote: input.resolutionNote ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_CHANGE_REQUEST_RESOLVE',
entityType: 'MentorChangeRequest',
entityId: existing.id,
detailsJson: {
status: input.status,
projectId: existing.projectId,
},
})
return updated
}),
})