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): 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,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-22 16:38:14 +02:00
|
|
|
// Send per-team email notification once per assignment row. Idempotency
|
|
|
|
|
// is enforced via MentorAssignment.notificationSentAt — a fresh row has
|
|
|
|
|
// it null. If the same mentor is later dropped and re-assigned (new row,
|
|
|
|
|
// fresh id), a new email is sent — intentional.
|
|
|
|
|
if (assignment.notificationSentAt == null && assignment.mentor.email) {
|
|
|
|
|
await sendMentorTeamAssignmentEmail(
|
|
|
|
|
assignment.mentor.email,
|
|
|
|
|
assignment.mentor.name,
|
|
|
|
|
assignment.project.title,
|
|
|
|
|
input.projectId,
|
|
|
|
|
)
|
|
|
|
|
try {
|
|
|
|
|
await ctx.prisma.mentorAssignment.update({
|
|
|
|
|
where: { id: assignment.id },
|
|
|
|
|
data: { notificationSentAt: new Date() },
|
|
|
|
|
})
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('[Mentor] failed to stamp notificationSentAt (non-fatal):', e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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): 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
|
|
|
// One email per mentor, listing only their NEW projects.
|
|
|
|
|
for (const bucket of perMentor.values()) {
|
|
|
|
|
if (bucket.newProjects.length === 0 || !bucket.email) continue
|
|
|
|
|
await sendMentorBulkAssignmentEmail(
|
|
|
|
|
bucket.email,
|
|
|
|
|
bucket.name,
|
|
|
|
|
bucket.newProjects,
|
|
|
|
|
)
|
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): 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: { id: { in: bucket.assignmentIds } },
|
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): 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
|
|
|
// One team-intro email per touched project (only if MENTORING round
|
|
|
|
|
// is currently ROUND_ACTIVE). The helper lists ALL active mentors on
|
|
|
|
|
// the project, including any pre-existing co-mentors.
|
|
|
|
|
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): 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
|
|
|
// Send one coalesced email per mentor, then stamp notificationSentAt so
|
|
|
|
|
// re-running the bulk doesn't double-notify.
|
|
|
|
|
for (const bucket of perMentor.values()) {
|
|
|
|
|
if (!bucket.email || bucket.projects.length === 0) continue
|
|
|
|
|
await sendMentorBulkAssignmentEmail(
|
|
|
|
|
bucket.email,
|
|
|
|
|
bucket.name,
|
|
|
|
|
bucket.projects,
|
|
|
|
|
)
|
|
|
|
|
try {
|
|
|
|
|
await ctx.prisma.mentorAssignment.updateMany({
|
|
|
|
|
where: { id: { in: bucket.assignmentIds } },
|
|
|
|
|
data: { notificationSentAt: new Date() },
|
|
|
|
|
})
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(
|
|
|
|
|
'[Mentor.autoAssignBulkForRound] failed to stamp notificationSentAt (non-fatal):',
|
|
|
|
|
e,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If the mentoring round is already open at the time of bulk auto-fill,
|
|
|
|
|
// introduce each team to their new mentor(s). If the round is still
|
|
|
|
|
// DRAFT, the activation hook will email later.
|
|
|
|
|
const roundStatus = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { status: true },
|
|
|
|
|
})
|
|
|
|
|
if (roundStatus?.status === 'ROUND_ACTIVE') {
|
|
|
|
|
const introducedProjects = new Set<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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
})
|