2026-02-14 15:26:42 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
2026-05-22 16:59:23 +02:00
|
|
|
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,
|
2026-05-22 16:59:23 +02:00
|
|
|
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,
|
2026-05-22 16:59:23 +02:00
|
|
|
} from '@/lib/email'
|
2026-02-14 15:26:42 +01:00
|
|
|
import {
|
|
|
|
|
getAIMentorSuggestions,
|
|
|
|
|
getRoundRobinMentor,
|
2026-04-28 14:54:43 +02:00
|
|
|
computeExpertiseOverlap,
|
2026-02-14 15:26:42 +01:00
|
|
|
} from '../services/mentor-matching'
|
2026-04-28 14:54:43 +02:00
|
|
|
import { getOpenAI } from '@/lib/openai'
|
2026-02-14 15:26:42 +01:00
|
|
|
import {
|
|
|
|
|
createNotification,
|
|
|
|
|
notifyProjectTeam,
|
|
|
|
|
NotificationTypes,
|
|
|
|
|
} from '../services/in-app-notification'
|
|
|
|
|
import { logAudit } from '@/server/utils/audit'
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
import {
|
|
|
|
|
activateWorkspace,
|
|
|
|
|
sendMessage as workspaceSendMessage,
|
|
|
|
|
getMessages as workspaceGetMessages,
|
|
|
|
|
markRead as workspaceMarkRead,
|
|
|
|
|
uploadFile as workspaceUploadFile,
|
|
|
|
|
addFileComment as workspaceAddFileComment,
|
|
|
|
|
promoteFile as workspacePromoteFile,
|
2026-04-28 13:33:18 +02:00
|
|
|
getFiles as workspaceGetFilesService,
|
|
|
|
|
deleteFile as workspaceDeleteFileService,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
} from '../services/mentor-workspace'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { triggerInProgressOnActivity } from '../services/round-engine'
|
2026-04-28 13:33:18 +02:00
|
|
|
import {
|
|
|
|
|
generateMentorObjectKey,
|
|
|
|
|
getPresignedUrl,
|
|
|
|
|
BUCKET_NAME,
|
|
|
|
|
deleteObject,
|
|
|
|
|
} from '@/lib/minio'
|
|
|
|
|
import {
|
|
|
|
|
signMentorUploadToken,
|
|
|
|
|
verifyMentorUploadToken,
|
|
|
|
|
} from '@/lib/mentor-upload-token'
|
|
|
|
|
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 13:33:18 +02:00
|
|
|
/**
|
|
|
|
|
* 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' })
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
2026-05-22 16:53:07 +02:00
|
|
|
/**
|
|
|
|
|
* 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' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
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: {
|
2026-05-22 16:37:37 +02:00
|
|
|
mentorAssignments: true,
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-22 16:38:14 +02:00
|
|
|
// 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.
|
2026-05-22 16:37:37 +02:00
|
|
|
const primaryMentor = project.mentorAssignments[0] ?? null
|
2026-02-14 15:26:42 +01:00
|
|
|
|
2026-04-28 14:54:43 +02:00
|
|
|
// 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'
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
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 {
|
2026-05-22 16:38:14 +02:00
|
|
|
// TODO(PR8 Task 8): return the full mentor list. Legacy field kept
|
|
|
|
|
// until the admin UI is updated.
|
|
|
|
|
currentMentor: primaryMentor,
|
2026-02-14 15:26:42 +01:00
|
|
|
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
|
2026-04-28 14:54:43 +02:00
|
|
|
source,
|
2026-02-14 15:26:42 +01:00
|
|
|
message: null,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-28 14:54:43 +02:00
|
|
|
/**
|
|
|
|
|
* 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' },
|
2026-04-28 15:32:28 +02:00
|
|
|
status: { not: 'SUSPENDED' },
|
2026-04-28 14:54:43 +02:00
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
country: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
maxAssignments: true,
|
2026-04-28 18:44:45 +02:00
|
|
|
mentorAssignments: { where: { droppedAt: null }, select: { id: true } },
|
2026-04-28 14:54:43 +02:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* 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 }) => {
|
2026-05-22 16:38:14 +02:00
|
|
|
// Verify project exists (multi-mentor: stacking is allowed; duplicate
|
|
|
|
|
// (projectId, mentorId) pairs are rejected by the unique constraint
|
|
|
|
|
// below).
|
2026-02-14 15:26:42 +01:00
|
|
|
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 },
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-22 16:38:14 +02:00
|
|
|
// 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,
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
2026-05-22 16:38:14 +02:00
|
|
|
include: {
|
|
|
|
|
mentor: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
},
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
},
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
2026-05-22 16:38:14 +02:00
|
|
|
})
|
|
|
|
|
} 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
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// 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,
|
2026-05-22 16:38:14 +02:00
|
|
|
// PR8: per-team assignment (one row per mentor-project pair).
|
|
|
|
|
assignmentScope: 'per-team',
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
// 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
|
|
|
|
|
) {
|
2026-05-22 16:38:14 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// 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)
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
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 }) => {
|
2026-05-22 16:37:37 +02:00
|
|
|
// 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.
|
2026-02-14 15:26:42 +01:00
|
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.projectId },
|
2026-05-22 16:37:37 +02:00
|
|
|
include: { mentorAssignments: { select: { id: true } } },
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
2026-05-22 16:37:37 +02:00
|
|
|
if (project.mentorAssignments.length > 0) {
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
/**
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +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({
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
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 }) => {
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
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
|
|
|
})
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +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',
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
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: {
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
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
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +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
|
|
|
}
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +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
|
|
|
})
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +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
|
|
|
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +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
|
|
|
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +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
|
|
|
},
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +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
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +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.
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
for (const bucket of perMentor.values()) {
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
if (!bucket.email) continue
|
|
|
|
|
const sendableProjects = bucket.newProjects.filter(
|
|
|
|
|
(p) => !draftProjectIds.has(p.id),
|
|
|
|
|
)
|
|
|
|
|
if (sendableProjects.length === 0) continue
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
await sendMentorBulkAssignmentEmail(
|
|
|
|
|
bucket.email,
|
|
|
|
|
bucket.name,
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
sendableProjects,
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
)
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
// 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({
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
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() },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
// 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).
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
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',
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
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: {
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
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 {
|
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
2026-05-22 17:11:31 +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).
|
2026-02-14 15:26:42 +01:00
|
|
|
*/
|
|
|
|
|
unassign: adminProcedure
|
2026-05-22 17:11:31 +02:00
|
|
|
.input(
|
|
|
|
|
z
|
|
|
|
|
.object({
|
|
|
|
|
assignmentId: z.string().optional(),
|
|
|
|
|
projectId: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
.refine((v) => !!v.assignmentId || !!v.projectId, {
|
|
|
|
|
message: 'Either assignmentId or projectId is required',
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-02-14 15:26:42 +01:00
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-05-22 17:11:31 +02:00
|
|
|
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 } },
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
if (!assignment) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
2026-05-22 17:11:31 +02:00
|
|
|
message: 'No mentor assignment found',
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// Delete assignment
|
|
|
|
|
await ctx.prisma.mentorAssignment.delete({
|
2026-05-22 16:37:37 +02:00
|
|
|
where: { id: assignment.id },
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// 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: {
|
2026-05-22 17:11:31 +02:00
|
|
|
projectId: assignment.project.id,
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
projectTitle: assignment.project.title,
|
|
|
|
|
mentorId: assignment.mentor.id,
|
|
|
|
|
mentorName: assignment.mentor.name,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-22 16:37:37 +02:00
|
|
|
mentorAssignments: { none: {} },
|
2026-02-14 15:26:42 +01:00
|
|
|
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++
|
|
|
|
|
}
|
2026-03-07 16:18:24 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to send mentor assignment notifications:', err)
|
2026-02-14 15:26:42 +01:00
|
|
|
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`,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-28 14:54:43 +02:00
|
|
|
/**
|
|
|
|
|
* 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: {
|
2026-05-26 13:01:05 +02:00
|
|
|
mentorAssignments: { none: { droppedAt: null } },
|
2026-04-28 18:57:18 +02:00
|
|
|
// 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' },
|
2026-04-28 14:54:43 +02:00
|
|
|
...(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 }[]
|
|
|
|
|
}
|
|
|
|
|
>()
|
|
|
|
|
|
2026-04-28 14:54:43 +02:00
|
|
|
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 } },
|
2026-04-28 14:54:43 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-04-28 14:54:43 +02:00
|
|
|
assigned++
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(
|
|
|
|
|
'[Mentor] autoAssignBulkForRound failure for project',
|
|
|
|
|
project.id,
|
|
|
|
|
err,
|
|
|
|
|
)
|
|
|
|
|
unassignable++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
// 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 },
|
|
|
|
|
})
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
// 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
|
|
|
|
2026-04-28 14:54:43 +02:00
|
|
|
const skipped = await ctx.prisma.projectRoundState.count({
|
|
|
|
|
where: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
project: {
|
2026-05-22 16:37:37 +02:00
|
|
|
mentorAssignments: { some: {} },
|
2026-04-28 14:54:43 +02:00
|
|
|
...(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.`,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-28 15:24:07 +02:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-05-22 16:37:37 +02:00
|
|
|
project: { wantsMentorship: true, mentorAssignments: { some: {} } },
|
2026-04-28 15:24:07 +02:00
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.projectRoundState.count({
|
|
|
|
|
where: {
|
|
|
|
|
roundId: input.roundId,
|
2026-05-22 16:37:37 +02:00
|
|
|
project: { mentorAssignments: { some: {} } },
|
2026-04-28 15:24:07 +02:00
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
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' },
|
2026-04-28 15:32:28 +02:00
|
|
|
status: { not: 'SUSPENDED' },
|
2026-04-28 15:24:07 +02:00
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
country: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
maxAssignments: true,
|
|
|
|
|
mentorAssignments: {
|
2026-04-28 18:44:45 +02:00
|
|
|
where: {
|
|
|
|
|
droppedAt: null,
|
|
|
|
|
...(input.programId ? { project: { programId: input.programId } } : {}),
|
|
|
|
|
},
|
2026-04-28 15:24:07 +02:00
|
|
|
select: {
|
|
|
|
|
completionStatus: true,
|
2026-04-28 19:52:17 +02:00
|
|
|
project: { select: { id: true, title: true } },
|
2026-04-28 15:24:07 +02:00
|
|
|
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[] = []
|
2026-04-28 19:52:17 +02:00
|
|
|
const activeTeams: { id: string; title: string }[] = []
|
2026-04-28 15:24:07 +02:00
|
|
|
for (const a of m.mentorAssignments) {
|
2026-04-28 19:52:17 +02:00
|
|
|
if (a.completionStatus === 'completed') {
|
|
|
|
|
completed++
|
|
|
|
|
} else {
|
|
|
|
|
current++
|
|
|
|
|
activeTeams.push(a.project)
|
|
|
|
|
}
|
2026-04-28 15:24:07 +02:00
|
|
|
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,
|
2026-04-28 19:52:17 +02:00
|
|
|
activeTeams,
|
2026-04-28 15:24:07 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
mentors: enriched,
|
|
|
|
|
poolSize: enriched.length,
|
|
|
|
|
totalCurrentAssignments,
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-04-28 16:47:53 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-05-22 16:37:37 +02:00
|
|
|
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' },
|
2026-04-28 16:47:53 +02:00
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
method: true,
|
|
|
|
|
assignedAt: true,
|
|
|
|
|
completionStatus: true,
|
2026-04-28 18:44:45 +02:00
|
|
|
droppedAt: true,
|
2026-04-28 16:47:53 +02:00
|
|
|
mentor: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
maxAssignments: true,
|
2026-04-28 18:44:45 +02:00
|
|
|
_count: {
|
|
|
|
|
select: {
|
|
|
|
|
mentorAssignments: { where: { droppedAt: null } },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-28 16:47:53 +02:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
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) => {
|
2026-04-28 18:44:45 +02:00
|
|
|
// Treat a dropped mentor assignment as if no mentor is assigned.
|
2026-05-22 16:37:37 +02:00
|
|
|
// 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
|
2026-04-28 16:47:53 +02:00
|
|
|
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 }
|
|
|
|
|
}),
|
2026-04-28 15:24:07 +02:00
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* Get mentor's assigned projects
|
|
|
|
|
*/
|
|
|
|
|
getMyProjects: mentorProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const assignments = await ctx.prisma.mentorAssignment.findMany({
|
2026-04-28 18:44:45 +02:00
|
|
|
where: { mentorId: ctx.user.id, droppedAt: null },
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
|
|
}),
|
|
|
|
|
|
2026-05-22 17:07:11 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* 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' },
|
|
|
|
|
},
|
2026-05-22 16:37:37 +02:00
|
|
|
mentorAssignments: {
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-28 16:14:11 +02:00
|
|
|
/**
|
|
|
|
|
* 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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
})
|
2026-03-07 16:18:24 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Mentor] Audit log failed:', err)
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
})
|
2026-03-07 16:18:24 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Mentor] Audit log failed:', err)
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// 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
|
|
|
|
|
*/
|
2026-04-29 03:13:01 +02:00
|
|
|
workspaceSendMessage: protectedProcedure
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
.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 }) => {
|
2026-04-29 03:13:01 +02:00
|
|
|
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return workspaceSendMessage(
|
|
|
|
|
{
|
2026-03-10 12:47:06 +01:00
|
|
|
workspaceId: input.mentorAssignmentId,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
senderId: ctx.user.id,
|
|
|
|
|
message: input.message,
|
|
|
|
|
role: input.role,
|
|
|
|
|
},
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get workspace messages
|
|
|
|
|
*/
|
2026-04-29 03:13:01 +02:00
|
|
|
workspaceGetMessages: protectedProcedure
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
.input(z.object({ mentorAssignmentId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
2026-04-29 03:13:01 +02:00
|
|
|
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return workspaceGetMessages(input.mentorAssignmentId, ctx.prisma)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 03:13:01 +02:00
|
|
|
* 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).
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
*/
|
2026-04-29 03:13:01 +02:00
|
|
|
workspaceMarkRead: protectedProcedure
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
.input(z.object({ messageId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-04-29 03:13:01 +02:00
|
|
|
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 }
|
|
|
|
|
}
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
await workspaceMarkRead(input.messageId, ctx.prisma)
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-28 13:33:18 +02:00
|
|
|
* 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.
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
*/
|
2026-04-28 13:33:18 +02:00
|
|
|
workspaceGetUploadUrl: protectedProcedure
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
mentorAssignmentId: z.string(),
|
|
|
|
|
fileName: z.string().min(1).max(255),
|
2026-04-28 13:33:18 +02:00
|
|
|
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,
|
2026-05-22 16:53:07 +02:00
|
|
|
projectId: assignment.projectId,
|
2026-04-28 13:33:18 +02:00
|
|
|
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(),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
description: z.string().max(2000).optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-04-28 13:33:18 +02:00
|
|
|
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)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return workspaceUploadFile(
|
|
|
|
|
{
|
2026-04-28 13:33:18 +02:00
|
|
|
workspaceId: payload.mentorAssignmentId,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
uploadedByUserId: ctx.user.id,
|
2026-04-28 13:33:18 +02:00
|
|
|
fileName: payload.fileName,
|
|
|
|
|
mimeType: payload.mimeType,
|
|
|
|
|
size: payload.size,
|
|
|
|
|
bucket: payload.bucket,
|
|
|
|
|
objectKey: payload.objectKey,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
description: input.description,
|
|
|
|
|
},
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-28 13:33:18 +02:00
|
|
|
/**
|
2026-05-22 16:53:07 +02:00
|
|
|
* 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.
|
2026-04-28 13:33:18 +02:00
|
|
|
*/
|
|
|
|
|
workspaceGetFiles: protectedProcedure
|
2026-05-22 16:53:07 +02:00
|
|
|
.input(z.object({ projectId: z.string() }))
|
2026-04-28 13:33:18 +02:00
|
|
|
.query(async ({ ctx, input }) => {
|
2026-05-22 16:53:07 +02:00
|
|
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
|
|
|
|
|
return workspaceGetFilesService(input.projectId, ctx.prisma)
|
2026-04-28 13:33:18 +02:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Issue a short-lived presigned GET URL to download a workspace file.
|
|
|
|
|
*/
|
|
|
|
|
workspaceGetFileDownloadUrl: protectedProcedure
|
2026-05-22 18:26:20 +02:00
|
|
|
.input(z.object({
|
|
|
|
|
mentorFileId: z.string(),
|
|
|
|
|
disposition: z.enum(['inline', 'attachment']).default('attachment'),
|
|
|
|
|
}))
|
2026-04-28 13:33:18 +02:00
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const file = await ctx.prisma.mentorFile.findUnique({
|
|
|
|
|
where: { id: input.mentorFileId },
|
2026-05-22 18:26:20 +02:00
|
|
|
select: { bucket: true, objectKey: true, fileName: true, mimeType: true, projectId: true },
|
2026-04-28 13:33:18 +02:00
|
|
|
})
|
|
|
|
|
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
2026-05-22 16:53:07 +02:00
|
|
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
2026-04-28 13:33:18 +02:00
|
|
|
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
|
2026-05-22 18:26:20 +02:00
|
|
|
input.disposition === 'inline'
|
|
|
|
|
? { inline: true, contentType: file.mimeType }
|
|
|
|
|
: { downloadFileName: file.fileName })
|
2026-04-28 13:33:18 +02:00
|
|
|
return { url }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-22 16:53:07 +02:00
|
|
|
* 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.
|
2026-04-28 13:33:18 +02:00
|
|
|
*/
|
|
|
|
|
workspaceDeleteFile: protectedProcedure
|
|
|
|
|
.input(z.object({ mentorFileId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const file = await ctx.prisma.mentorFile.findUnique({
|
|
|
|
|
where: { id: input.mentorFileId },
|
2026-05-22 16:53:07 +02:00
|
|
|
select: { projectId: true },
|
2026-04-28 13:33:18 +02:00
|
|
|
})
|
|
|
|
|
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
2026-05-22 16:53:07 +02:00
|
|
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
2026-04-28 13:33:18 +02:00
|
|
|
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 }
|
|
|
|
|
}),
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
/**
|
|
|
|
|
* Add a comment to a workspace file
|
|
|
|
|
*/
|
2026-04-29 03:13:01 +02:00
|
|
|
workspaceAddFileComment: protectedProcedure
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
mentorFileId: z.string(),
|
|
|
|
|
content: z.string().min(1).max(5000),
|
|
|
|
|
parentCommentId: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-04-29 03:13:01 +02:00
|
|
|
const file = await ctx.prisma.mentorFile.findUnique({
|
|
|
|
|
where: { id: input.mentorFileId },
|
2026-05-22 16:53:07 +02:00
|
|
|
select: { projectId: true },
|
2026-04-29 03:13:01 +02:00
|
|
|
})
|
|
|
|
|
if (!file) {
|
|
|
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
|
|
|
|
}
|
2026-05-22 16:53:07 +02:00
|
|
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
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
|
|
|
|
|
}),
|
2026-04-28 18:44:45 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}),
|
2026-04-28 19:52:17 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-05-22 16:59:23 +02:00
|
|
|
|
|
|
|
|
// ===========================================================================
|
|
|
|
|
// 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
|
|
|
|
|
}),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|