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-04-28 13:33:18 +02:00
|
|
|
import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client'
|
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'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
|
|
|
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: {
|
|
|
|
|
mentorAssignment: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (project.mentorAssignment) {
|
|
|
|
|
return {
|
|
|
|
|
currentMentor: project.mentorAssignment,
|
|
|
|
|
suggestions: [],
|
2026-04-28 14:54:43 +02:00
|
|
|
source: 'ai' as const,
|
2026-02-14 15:26:42 +01:00
|
|
|
message: 'Project already has a mentor assigned',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 {
|
|
|
|
|
currentMentor: null,
|
|
|
|
|
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 }) => {
|
|
|
|
|
// Verify project exists and doesn't have a mentor
|
|
|
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
include: { mentorAssignment: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (project.mentorAssignment) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'Project already has a mentor assigned',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify mentor exists
|
|
|
|
|
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.mentorId },
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
// Create assignment
|
|
|
|
|
const assignment = await ctx.prisma.mentorAssignment.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
mentorId: input.mentorId,
|
|
|
|
|
method: input.method,
|
|
|
|
|
assignedBy: ctx.user.id,
|
|
|
|
|
aiConfidenceScore: input.aiConfidenceScore,
|
|
|
|
|
expertiseMatchScore: input.expertiseMatchScore,
|
|
|
|
|
aiReasoning: input.aiReasoning,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
mentor: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
expertiseTags: true,
|
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
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
},
|
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
|
|
|
},
|
|
|
|
|
})
|
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,
|
|
|
|
|
},
|
|
|
|
|
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-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)
|
|
|
|
|
}
|
|
|
|
|
|
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 }) => {
|
|
|
|
|
// Verify project exists and doesn't have a mentor
|
|
|
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
include: { mentorAssignment: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (project.mentorAssignment) {
|
|
|
|
|
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
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove mentor assignment
|
|
|
|
|
*/
|
|
|
|
|
unassign: adminProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const assignment = await ctx.prisma.mentorAssignment.findUnique({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
include: {
|
|
|
|
|
mentor: { select: { id: true, name: true } },
|
|
|
|
|
project: { select: { id: true, title: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!assignment) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'No mentor assignment found for this project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
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({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
})
|
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: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
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,
|
|
|
|
|
mentorAssignment: null,
|
|
|
|
|
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: {
|
|
|
|
|
mentorAssignment: 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
|
|
|
|
|
|
|
|
|
|
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: {
|
|
|
|
|
mentor: { select: { id: true, name: true } },
|
|
|
|
|
project: { select: { title: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const teamLead = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: project.id, role: 'LEAD' },
|
|
|
|
|
include: { user: { select: { name: true, email: true } } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: mentorId,
|
|
|
|
|
type: NotificationTypes.MENTEE_ASSIGNED,
|
|
|
|
|
title: 'New Mentee Assigned',
|
|
|
|
|
message: `You have been assigned to mentor "${assignment.project.title}".`,
|
|
|
|
|
linkUrl: `/mentor/projects/${project.id}`,
|
|
|
|
|
linkLabel: 'View Project',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: assignment.project.title,
|
|
|
|
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
|
|
|
|
teamLeadEmail: teamLead?.user?.email,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await notifyProjectTeam(project.id, {
|
|
|
|
|
type: NotificationTypes.MENTOR_ASSIGNED,
|
|
|
|
|
title: 'Mentor Assigned',
|
|
|
|
|
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
|
|
|
|
|
linkUrl: `/team/projects/${project.id}`,
|
|
|
|
|
linkLabel: 'View Project',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: assignment.project.title,
|
|
|
|
|
mentorName: assignment.mentor.name,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
assigned++
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(
|
|
|
|
|
'[Mentor] autoAssignBulkForRound failure for project',
|
|
|
|
|
project.id,
|
|
|
|
|
err,
|
|
|
|
|
)
|
|
|
|
|
unassignable++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const skipped = await ctx.prisma.projectRoundState.count({
|
|
|
|
|
where: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
project: {
|
|
|
|
|
mentorAssignment: { isNot: null },
|
|
|
|
|
...(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,
|
|
|
|
|
project: { wantsMentorship: true, mentorAssignment: { isNot: null } },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.projectRoundState.count({
|
|
|
|
|
where: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
project: { mentorAssignment: { isNot: null } },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
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,
|
|
|
|
|
mentorAssignment: {
|
|
|
|
|
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.
|
|
|
|
|
const ma = p.mentorAssignment && !p.mentorAssignment.droppedAt ? p.mentorAssignment : null
|
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
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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' },
|
|
|
|
|
},
|
|
|
|
|
mentorAssignment: {
|
|
|
|
|
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
|
|
|
|
|
*/
|
|
|
|
|
workspaceSendMessage: mentorProcedure
|
|
|
|
|
.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 }) => {
|
|
|
|
|
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
|
|
|
|
|
*/
|
|
|
|
|
workspaceGetMessages: mentorProcedure
|
|
|
|
|
.input(z.object({ mentorAssignmentId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return workspaceGetMessages(input.mentorAssignmentId, ctx.prisma)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mark a workspace message as read
|
|
|
|
|
*/
|
|
|
|
|
workspaceMarkRead: mentorProcedure
|
|
|
|
|
.input(z.object({ messageId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
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,
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* List files in a workspace. Authorized for the assigned mentor or any
|
|
|
|
|
* project team member.
|
|
|
|
|
*/
|
|
|
|
|
workspaceGetFiles: protectedProcedure
|
|
|
|
|
.input(z.object({ mentorAssignmentId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
|
|
|
|
|
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Issue a short-lived presigned GET URL to download a workspace file.
|
|
|
|
|
*/
|
|
|
|
|
workspaceGetFileDownloadUrl: protectedProcedure
|
|
|
|
|
.input(z.object({ mentorFileId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const file = await ctx.prisma.mentorFile.findUnique({
|
|
|
|
|
where: { id: input.mentorFileId },
|
|
|
|
|
select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true },
|
|
|
|
|
})
|
|
|
|
|
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
|
|
|
|
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
|
|
|
|
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
|
|
|
|
|
{ downloadFileName: file.fileName })
|
|
|
|
|
return { url }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a workspace file (uploader or assigned mentor only).
|
|
|
|
|
*/
|
|
|
|
|
workspaceDeleteFile: protectedProcedure
|
|
|
|
|
.input(z.object({ mentorFileId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const file = await ctx.prisma.mentorFile.findUnique({
|
|
|
|
|
where: { id: input.mentorFileId },
|
|
|
|
|
select: { mentorAssignmentId: true },
|
|
|
|
|
})
|
|
|
|
|
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
|
|
|
|
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
|
|
|
|
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
|
|
|
|
|
*/
|
|
|
|
|
workspaceAddFileComment: mentorProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
mentorFileId: z.string(),
|
|
|
|
|
content: z.string().min(1).max(5000),
|
|
|
|
|
parentCommentId: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
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-02-14 15:26:42 +01:00
|
|
|
})
|