diff --git a/prisma/migrations/20260526114936_mentor_assignment_team_introduced_at/migration.sql b/prisma/migrations/20260526114936_mentor_assignment_team_introduced_at/migration.sql new file mode 100644 index 0000000..fa32b14 --- /dev/null +++ b/prisma/migrations/20260526114936_mentor_assignment_team_introduced_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "MentorAssignment" ADD COLUMN "teamIntroducedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b448f7b..865e520 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1281,9 +1281,16 @@ model MentorAssignment { assignedAt DateTime @default(now()) assignedBy String? // Admin who assigned - // Per-assignment email idempotency: stamped once the assignment notification email is sent. + // Per-assignment email idempotency: stamped once the MENTOR-side notification + // email has been sent (the "you've been assigned a project" email to the mentor). notificationSentAt DateTime? + // Stamped once the TEAM has been introduced to this mentor (the "meet your + // mentor" email with mentor contact info). Fired by `activateRound` for + // MENTORING rounds and by mentor.assign when the project's MENTORING round + // is already ROUND_ACTIVE. Independent from notificationSentAt above. + teamIntroducedAt DateTime? + // AI assignment metadata aiConfidenceScore Float? expertiseMatchScore Float? diff --git a/src/components/admin/round/mentoring-projects-table.tsx b/src/components/admin/round/mentoring-projects-table.tsx index 60cba7b..4389759 100644 --- a/src/components/admin/round/mentoring-projects-table.tsx +++ b/src/components/admin/round/mentoring-projects-table.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import Link from 'next/link' +import { toast } from 'sonner' import { trpc } from '@/lib/trpc/client' import { Table, @@ -15,7 +16,24 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' -import { Search, UserPlus, ArrowRight, Sparkles } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Search, + UserPlus, + ArrowRight, + Sparkles, + Loader2, + Download, + X, +} from 'lucide-react' import { CountryDisplay } from '@/components/shared/country-display' type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only' @@ -23,12 +41,106 @@ type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only' export function MentoringProjectsTable({ roundId }: { roundId: string }) { const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') + const [selected, setSelected] = useState>(new Set()) + const [bulkOpen, setBulkOpen] = useState(false) + const [chosenMentorId, setChosenMentorId] = useState('') + const [mentorSearch, setMentorSearch] = useState('') + + const utils = trpc.useUtils() const { data, isLoading } = trpc.round.listMentoringProjects.useQuery( { roundId }, { refetchInterval: 30_000 }, ) + const { data: importCandidates } = + trpc.round.getMentoringImportCandidates.useQuery({ roundId }) + + const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery( + {}, + { enabled: bulkOpen }, + ) + + const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({ + onSuccess: (result) => { + if (result.assignedCount === 0 && result.skippedCount > 0) { + toast.info( + `No new assignments — the selected mentor is already on all ${result.skippedCount} project${result.skippedCount === 1 ? '' : 's'}.`, + ) + } else { + toast.success( + `Assigned mentor to ${result.assignedCount} project${ + result.assignedCount === 1 ? '' : 's' + }${result.skippedCount > 0 ? ` (${result.skippedCount} already had this mentor)` : ''}${ + result.emailSent ? ' · email sent' : '' + }`, + ) + } + utils.round.listMentoringProjects.invalidate({ roundId }) + utils.round.getProjectsNeedingMentor.invalidate({ roundId }) + utils.round.getMentoringImportCandidates.invalidate({ roundId }) + utils.mentor.getMentorPool.invalidate() + utils.mentor.getRoundStats.invalidate({ roundId }) + utils.project.list.invalidate() + setSelected(new Set()) + setChosenMentorId('') + setMentorSearch('') + setBulkOpen(false) + }, + onError: (err) => toast.error(err.message), + }) + + const advanceMutation = trpc.round.advanceProjects.useMutation({ + onSuccess: (result) => { + toast.success( + `Imported ${result.advancedCount} project${ + result.advancedCount === 1 ? '' : 's' + } from ${result.targetRoundName ? '' : ''}${ + importCandidates?.priorRound?.name ?? 'the prior round' + }`, + ) + utils.round.listMentoringProjects.invalidate({ roundId }) + utils.round.getMentoringImportCandidates.invalidate({ roundId }) + utils.round.getProjectsNeedingMentor.invalidate({ roundId }) + }, + onError: (err) => toast.error(err.message), + }) + + const importBanner = importCandidates?.priorRound && + importCandidates.pendingCount > 0 && ( +
+
+ + {importCandidates.pendingCount} PASSED project + {importCandidates.pendingCount === 1 ? '' : 's'} + {' '} + from{' '} + + {importCandidates.priorRound.name} + {' '} + {importCandidates.pendingCount === 1 ? "isn't" : "aren't"} in this + mentoring round yet. +
+ +
+ ) + const filtered = useMemo(() => { if (!data) return [] const q = search.trim().toLowerCase() @@ -72,8 +184,20 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) { if (!data || data.projects.length === 0) { return ( -
- No projects in this mentoring round yet. +
+ {importBanner} +
+ No projects in this mentoring round yet. + {!importBanner && ( + <> + {' '}Use{' '} + + Add Project to Round + {' '} + to populate it. + + )} +
) } @@ -103,6 +227,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) { return (
+ {importBanner}
@@ -121,10 +246,80 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
+ {selected.size > 0 ? ( +
+
+ {selected.size}{' '} + + project{selected.size === 1 ? '' : 's'} selected + +
+
+ + +
+
+ ) : ( +
+ + Tip: tick checkboxes to bulk-assign one mentor to multiple + projects in a single click (mentor gets one combined email). + + {totals.unassigned > 0 && ( + + )} +
+ )} +
+ + 0 && + filtered.every((p) => selected.has(p.id)) + } + onCheckedChange={(checked) => { + setSelected((prev) => { + const next = new Set(prev) + if (checked) { + filtered.forEach((p) => next.add(p.id)) + } else { + filtered.forEach((p) => next.delete(p.id)) + } + return next + }) + }} + aria-label="Select all visible" + /> + Project Wants? Mentors @@ -135,7 +330,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) { {filtered.length === 0 ? ( No projects match the current filter. @@ -143,7 +338,24 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) { ) : ( filtered.map((p) => ( - + + + + setSelected((prev) => { + const next = new Set(prev) + if (checked) next.add(p.id) + else next.delete(p.id) + return next + }) + } + aria-label={`Select ${p.title}`} + /> +
{p.title}
@@ -224,6 +436,162 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
+ + { + if (!next) { + setBulkOpen(false) + setChosenMentorId('') + setMentorSearch('') + } + }} + > + + + + Assign mentor to {selected.size} project + {selected.size === 1 ? '' : 's'} + + + Choose one mentor — they'll receive a single email listing every + new assignment. Projects where they're already an active mentor + will be skipped. + + + +
+
+ + setMentorSearch(e.target.value)} + placeholder="Search mentor by name, email, country, or expertise…" + className="pl-8" + /> +
+
+ {(() => { + const mentors = mentorPool?.mentors ?? [] + const q = mentorSearch.trim().toLowerCase() + const filteredMentors = q + ? mentors.filter((m) => + [ + m.name ?? '', + m.email, + m.country ?? '', + ...(m.expertiseTags ?? []), + ] + .join(' ') + .toLowerCase() + .includes(q), + ) + : mentors + if (mentors.length === 0) { + return ( +

+ No mentors in the pool yet.{' '} + + Add mentors + + . +

+ ) + } + if (filteredMentors.length === 0) { + return ( +

+ No mentors match “{mentorSearch}”. +

+ ) + } + return filteredMentors.map((m) => { + const isChosen = chosenMentorId === m.id + return ( + + ) + }) + })()} +
+
+ + + + + +
+
) } diff --git a/src/lib/email.ts b/src/lib/email.ts index adb6dca..d47b9a7 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -2832,6 +2832,217 @@ export async function sendMentorTeamAssignmentEmail( } } +function getTeamMentorIntroductionTemplate( + recipientName: string | null, + projectTitle: string, + mentors: { name: string | null; email: string }[], + workspaceUrl: string, +): EmailTemplate { + const count = mentors.length + const subject = + count === 1 + ? `Your mentor for "${projectTitle}" on MOPC` + : `Your ${count} mentors for "${projectTitle}" on MOPC` + const greeting = recipientName ? `Hi ${recipientName},` : 'Hi there,' + + const mentorTextLines = mentors + .map( + (m) => ` • ${m.name ?? 'Mentor'} — ${m.email}`, + ) + .join('\n') + + const text = [ + greeting, + '', + count === 1 + ? `The mentoring round is now open, and your project "${projectTitle}" has a mentor:` + : `The mentoring round is now open, and your project "${projectTitle}" has ${count} mentors:`, + '', + mentorTextLines, + '', + 'You can chat with them, share files, and track milestones in your mentor workspace:', + workspaceUrl, + '', + 'Feel free to reach out to them directly by email as well.', + '', + 'The MOPC team', + ].join('\n') + + const mentorHtmlList = mentors + .map( + (m) => ` + + ${escapeHtml(m.name ?? 'Mentor')} + + ${escapeHtml(m.email)} + + `, + ) + .join('') + + const html = ` + + + +
+
+

${count === 1 ? 'Meet your mentor' : `Meet your ${count} mentors`}

+
+
+

${recipientName ? `Hi ${escapeHtml(recipientName)},` : 'Hi there,'}

+

${count === 1 + ? `The mentoring round is now open and a mentor has been assigned to your project ${escapeHtml(projectTitle)}:` + : `The mentoring round is now open and ${count} mentors have been assigned to your project ${escapeHtml(projectTitle)}:`}

+ ${mentorHtmlList}
+

+ Open Mentor Workspace +

+

+ You can chat with them, share files, and track milestones in the workspace — or reach out to them directly by email. +

+
+
+ Monaco Ocean Protection Challenge +
+
+ + + `.trim() + + return { subject, text, html } +} + +/** + * Introduce a project team to their assigned mentor(s), with each mentor's + * name + email so the team can reach out directly. Sent when the MENTORING + * round opens AND any time a mentor is added to a project whose mentoring + * round is already open. Never throws. + */ +export async function sendTeamMentorIntroductionEmail( + recipientEmail: string, + recipientName: string | null, + projectTitle: string, + projectId: string, + mentors: { name: string | null; email: string }[], +): Promise { + try { + if (mentors.length === 0) return + const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '') + const workspaceUrl = `${baseUrl}/applicant/mentor` + const template = getTeamMentorIntroductionTemplate( + recipientName, + projectTitle, + mentors, + workspaceUrl, + ) + await sendEmail({ + to: recipientEmail, + subject: template.subject, + text: template.text, + html: template.html, + }) + } catch (error) { + console.error('[sendTeamMentorIntroductionEmail] failed', { recipientEmail, projectId, error }) + } +} + +function getMentorBulkAssignmentTemplate( + name: string, + projects: { title: string; url: string }[], + mentorDashboardUrl: string, +): EmailTemplate { + const count = projects.length + const subject = + count === 1 + ? `You've been assigned to a new MOPC project: "${projects[0].title}"` + : `You've been assigned to ${count} new MOPC projects` + const greeting = name ? `Hi ${name},` : 'Hi there,' + + const textLines = projects + .map((p) => ` • ${p.title} — ${p.url}`) + .join('\n') + const text = [ + greeting, + '', + count === 1 + ? `You have been assigned as a mentor to a new project:` + : `You have been assigned as a mentor to ${count} new projects:`, + '', + textLines, + '', + 'You may have co-mentors on these teams — you can collaborate together in each project workspace.', + '', + `Open your mentor dashboard: ${mentorDashboardUrl}`, + '', + 'The MOPC team', + ].join('\n') + + const htmlList = projects + .map( + (p) => + `
  • ${escapeHtml(p.title)}
  • `, + ) + .join('') + + const html = ` + + + +
    +
    +

    ${count === 1 ? 'New mentor assignment' : `${count} new mentor assignments`}

    +
    +
    +

    ${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}

    +

    ${count === 1 ? 'You have been assigned as a mentor to a new project:' : `You have been assigned as a mentor to ${count} new projects:`}

    +
      ${htmlList}
    +

    + Open Mentor Dashboard +

    +

    + You may have co-mentors on these teams — you can collaborate together in each project workspace. +

    +
    +
    + Monaco Ocean Protection Challenge +
    +
    + + + `.trim() + + return { subject, text, html } +} + +/** + * Send a coalesced mentor-assignment email when one mentor receives multiple + * project assignments in a single bulk operation. Caller passes the list of + * NEW assignments (already filtered to exclude any whose notificationSentAt + * was previously set). Never throws. + */ +export async function sendMentorBulkAssignmentEmail( + email: string, + name: string | null, + projects: { id: string; title: string }[], +): Promise { + try { + if (projects.length === 0) return + const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '') + const enriched = projects.map((p) => ({ + title: p.title, + url: `${baseUrl}/mentor/workspace/${p.id}`, + })) + const template = getMentorBulkAssignmentTemplate( + name || '', + enriched, + `${baseUrl}/mentor`, + ) + await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html }) + } catch (error) { + console.error('[sendMentorBulkAssignmentEmail] failed', { email, error }) + } +} + // ============================================================================= // Mentor change requests (PR 8) — admin notification when an applicant or admin // opens a MentorChangeRequest. Mentors are NOT notified (per design decision). diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 3684098..dbe7faf 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -8,8 +8,10 @@ import { type PrismaClient, } from '@prisma/client' import { + sendMentorBulkAssignmentEmail, sendMentorChangeRequestEmail, sendMentorTeamAssignmentEmail, + sendTeamMentorIntroductionEmail, } from '@/lib/email' import { getAIMentorSuggestions, @@ -46,6 +48,83 @@ import { verifyMentorUploadToken, } from '@/lib/mentor-upload-token' +/** + * 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 { + 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() + 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. @@ -414,6 +493,10 @@ export const mentorRouter = router({ console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e) } + // 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 }), @@ -564,6 +647,160 @@ export const mentorRouter = router({ return assignment }), + /** + * Bulk-assign ONE mentor to MANY projects in a single transaction. Skips + * projects where this mentor is already an active mentor. Sends a single + * coalesced email to the mentor listing all newly-assigned projects. + * In-app notifications are still per-project so each team is notified. + */ + bulkAssign: adminProcedure + .input( + z.object({ + mentorId: z.string(), + projectIds: z.array(z.string()).min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const mentor = await ctx.prisma.user.findUnique({ + where: { id: input.mentorId }, + select: { + id: true, + name: true, + email: true, + roles: true, + status: true, + }, + }) + if (!mentor || !mentor.roles.includes('MENTOR')) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Selected user is not a mentor', + }) + } + + const projects = await ctx.prisma.project.findMany({ + where: { id: { in: input.projectIds } }, + select: { + id: true, + title: true, + mentorAssignments: { + where: { mentorId: mentor.id, droppedAt: null }, + select: { id: true }, + }, + }, + }) + + const newProjects: { id: string; title: string }[] = [] + const skippedProjects: { id: string; title: string }[] = [] + const createdAssignmentIds: string[] = [] + + for (const p of projects) { + if (p.mentorAssignments.length > 0) { + skippedProjects.push({ id: p.id, title: p.title }) + continue + } + const created = await ctx.prisma.mentorAssignment.create({ + data: { + projectId: p.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: ctx.user.id, + }, + }) + createdAssignmentIds.push(created.id) + newProjects.push({ id: p.id, title: p.title }) + + await createNotification({ + userId: mentor.id, + type: NotificationTypes.MENTEE_ASSIGNED, + title: 'New Mentee Assigned', + message: `You have been assigned to mentor "${p.title}".`, + linkUrl: `/mentor/projects/${p.id}`, + linkLabel: 'View Project', + priority: 'high', + metadata: { projectName: p.title }, + }) + + await notifyProjectTeam(p.id, { + type: NotificationTypes.MENTOR_ASSIGNED, + title: 'Mentor Assigned', + message: `${mentor.name || 'A mentor'} has been assigned to support your project.`, + linkUrl: `/team/projects/${p.id}`, + linkLabel: 'View Project', + priority: 'high', + metadata: { projectName: p.title, mentorName: mentor.name }, + }) + + // Trigger MENTORING round IN_PROGRESS state transition (best-effort) + try { + const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({ + where: { + projectId: p.id, + round: { + roundType: 'MENTORING', + status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] }, + }, + state: 'PENDING', + }, + select: { roundId: true }, + }) + if (mentoringPrs) { + await triggerInProgressOnActivity( + p.id, + mentoringPrs.roundId, + ctx.user.id, + ctx.prisma, + ) + } + } catch (e) { + console.error( + '[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):', + e, + ) + } + } + + // One coalesced email per mentor, with all NEW project assignments. + if (newProjects.length > 0 && mentor.email) { + await sendMentorBulkAssignmentEmail(mentor.email, mentor.name, newProjects) + // Stamp notificationSentAt on every row we just created so single- + // assignment retries don't re-notify. + await ctx.prisma.mentorAssignment.updateMany({ + where: { id: { in: createdAssignmentIds } }, + data: { notificationSentAt: new Date() }, + }) + } + + // For each newly-assigned project whose MENTORING round is already open, + // introduce the team to the mentor(s) by email. + for (const p of newProjects) { + await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id) + } + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'MENTOR_BULK_ASSIGN', + entityType: 'User', + entityId: mentor.id, + detailsJson: { + mentorEmail: mentor.email, + assignedCount: newProjects.length, + skippedCount: skippedProjects.length, + newProjectIds: newProjects.map((p) => p.id), + }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + + return { + assignedCount: newProjects.length, + skippedCount: skippedProjects.length, + skippedProjects, + emailSent: newProjects.length > 0, + } + }), + /** * Remove mentor assignment. * @@ -842,6 +1079,18 @@ export const mentorRouter = router({ let assigned = 0 let unassignable = 0 + // 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 @@ -883,7 +1132,7 @@ export const mentorRouter = router({ aiReasoning, }, include: { - mentor: { select: { id: true, name: true } }, + mentor: { select: { id: true, name: true, email: true } }, project: { select: { title: true } }, }, }) @@ -921,6 +1170,17 @@ export const mentorRouter = router({ }, }) + // 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( @@ -932,6 +1192,46 @@ export const mentorRouter = router({ } } + // Send one coalesced email per mentor, then stamp notificationSentAt so + // re-running the bulk doesn't double-notify. + 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, + ) + } + } + + // If the mentoring round is already open at the time of bulk auto-fill, + // introduce each team to their new mentor(s). If the round is still + // DRAFT, the activation hook will email later. + const roundStatus = await ctx.prisma.round.findUnique({ + where: { id: input.roundId }, + select: { status: true }, + }) + if (roundStatus?.status === 'ROUND_ACTIVE') { + const introducedProjects = new Set() + 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) + } + } + } + const skipped = await ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index c735da0..7576ca8 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -266,6 +266,55 @@ export const roundRouter = router({ return { count, eligibleTotal, mentorPoolSize } }), + /** + * For a MENTORING round, find the immediately-prior round in the same + * competition and report how many of its PASSED projects are not yet + * present in this round. Drives the "Import from prior round" CTA so + * admins don't have to manually pick projects via the From-Round modal. + */ + getMentoringImportCandidates: adminProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + select: { roundType: true, competitionId: true, sortOrder: true }, + }) + if (round.roundType !== 'MENTORING') { + return { priorRound: null, pendingCount: 0 } + } + const prior = await ctx.prisma.round.findFirst({ + where: { + competitionId: round.competitionId, + sortOrder: { lt: round.sortOrder }, + }, + orderBy: { sortOrder: 'desc' }, + select: { id: true, name: true, status: true }, + }) + if (!prior) return { priorRound: null, pendingCount: 0 } + if (prior.status !== 'ROUND_ACTIVE' && prior.status !== 'ROUND_CLOSED') { + return { + priorRound: { id: prior.id, name: prior.name, status: prior.status }, + pendingCount: 0, + } + } + const existingInTarget = await ctx.prisma.projectRoundState.findMany({ + where: { roundId: input.roundId }, + select: { projectId: true }, + }) + const existingIds = new Set(existingInTarget.map((s) => s.projectId)) + const passedInPrior = await ctx.prisma.projectRoundState.findMany({ + where: { roundId: prior.id, state: 'PASSED' }, + select: { projectId: true }, + }) + const pendingCount = passedInPrior.filter( + (s) => !existingIds.has(s.projectId), + ).length + return { + priorRound: { id: prior.id, name: prior.name, status: prior.status }, + pendingCount, + } + }), + /** * List projects in a MENTORING round with their (multi-)mentor assignments. * Drives the per-team assignment table on the round Projects tab so admins diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts index 28fb437..e8990cb 100644 --- a/src/server/services/round-engine.ts +++ b/src/server/services/round-engine.ts @@ -16,6 +16,7 @@ import { logAudit } from '@/server/utils/audit' import { safeValidateRoundConfig } from '@/types/competition-configs' import { expireIntentsForRound } from './assignment-intent' import { processRoundClose } from './round-finalization' +import { sendTeamMentorIntroductionEmail } from '@/lib/email' // ─── Types ────────────────────────────────────────────────────────────────── @@ -211,6 +212,86 @@ export async function activateRound( } catch (mentoringError) { console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError) } + + // Introduce teams to their mentors via email when the round opens. + // Idempotent via MentorAssignment.teamIntroducedAt — separate from the + // mentor-side notificationSentAt so the team email fires even when the + // mentor was assigned (and notified) before the round opened. + try { + const projectsToIntroduce = await prisma.project.findMany({ + where: { + projectRoundStates: { some: { roundId } }, + mentorAssignments: { + some: { droppedAt: null, teamIntroducedAt: null }, + }, + }, + select: { + id: true, + title: true, + mentorAssignments: { + where: { droppedAt: null }, + select: { + id: true, + teamIntroducedAt: true, + mentor: { select: { name: true, email: true } }, + }, + }, + teamMembers: { + select: { user: { select: { name: true, email: true } } }, + }, + submittedByEmail: true, + submittedBy: { select: { name: true } }, + }, + }) + for (const p of projectsToIntroduce) { + const mentors = p.mentorAssignments + .filter((a) => a.mentor?.email) + .map((a) => ({ + name: a.mentor.name, + email: a.mentor.email, + })) + if (mentors.length === 0) continue + + // Build a unique recipient set: team-member users with emails, + // plus the original submitter (in case they're not on the team yet). + const recipients = new Map() + for (const tm of p.teamMembers) { + if (tm.user?.email) { + recipients.set(tm.user.email, { name: tm.user.name }) + } + } + if ( + p.submittedByEmail && + !recipients.has(p.submittedByEmail) + ) { + recipients.set(p.submittedByEmail, { + name: p.submittedBy?.name ?? null, + }) + } + + for (const [email, { name }] of recipients) { + await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors) + } + + // Stamp every mentor-assignment row so re-activation doesn't re-send. + const idsToStamp = p.mentorAssignments + .filter((a) => a.teamIntroducedAt == null) + .map((a) => a.id) + if (idsToStamp.length > 0) { + await prisma.mentorAssignment.updateMany({ + where: { id: { in: idsToStamp } }, + data: { teamIntroducedAt: new Date() }, + }) + } + } + if (projectsToIntroduce.length > 0) { + console.log( + `[RoundEngine] MENTORING round open: introduced mentors for ${projectsToIntroduce.length} project(s)`, + ) + } + } catch (introError) { + console.error('[RoundEngine] Team-mentor introduction failed (non-fatal):', introError) + } } return {