Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins: - F1: Evaluation progress indicator with touch tracking in sticky status bar - F2: Export filtering results as CSV with dynamic AI column flattening - F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure) Batch 2 - Jury Experience: - F4: Countdown timer component with urgency colors + email reminder service with cron endpoint - F5: Conflict of interest declaration system (dialog, admin management, review workflow) Batch 3 - Admin & AI Enhancements: - F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording - F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns - F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking) Batch 4 - Form Flexibility & Applicant Portal: - F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility) - F10: Applicant portal (status timeline, per-round documents, mentor messaging) Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
|
||||
|
||||
export const analyticsRouter = router({
|
||||
/**
|
||||
* Get score distribution for a round (histogram data)
|
||||
*/
|
||||
getScoreDistribution: adminProcedure
|
||||
getScoreDistribution: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
@@ -50,7 +50,7 @@ export const analyticsRouter = router({
|
||||
/**
|
||||
* Get evaluation completion over time (timeline data)
|
||||
*/
|
||||
getEvaluationTimeline: adminProcedure
|
||||
getEvaluationTimeline: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
@@ -96,7 +96,7 @@ export const analyticsRouter = router({
|
||||
/**
|
||||
* Get juror workload distribution
|
||||
*/
|
||||
getJurorWorkload: adminProcedure
|
||||
getJurorWorkload: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
@@ -145,7 +145,7 @@ export const analyticsRouter = router({
|
||||
/**
|
||||
* Get project rankings with average scores
|
||||
*/
|
||||
getProjectRankings: adminProcedure
|
||||
getProjectRankings: observerProcedure
|
||||
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
@@ -213,7 +213,7 @@ export const analyticsRouter = router({
|
||||
/**
|
||||
* Get status breakdown (pie chart data)
|
||||
*/
|
||||
getStatusBreakdown: adminProcedure
|
||||
getStatusBreakdown: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.groupBy({
|
||||
@@ -231,7 +231,7 @@ export const analyticsRouter = router({
|
||||
/**
|
||||
* Get overview stats for dashboard
|
||||
*/
|
||||
getOverviewStats: adminProcedure
|
||||
getOverviewStats: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [
|
||||
@@ -281,7 +281,7 @@ export const analyticsRouter = router({
|
||||
/**
|
||||
* Get criteria-level score distribution
|
||||
*/
|
||||
getCriteriaScores: adminProcedure
|
||||
getCriteriaScores: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get active evaluation form for this round
|
||||
@@ -343,7 +343,7 @@ export const analyticsRouter = router({
|
||||
/**
|
||||
* Get geographic distribution of projects by country
|
||||
*/
|
||||
getGeographicDistribution: adminProcedure
|
||||
getGeographicDistribution: observerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
|
||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||||
import { getPresignedUrl } from '@/lib/minio'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { createNotification } from '../services/in-app-notification'
|
||||
|
||||
// Bucket for applicant submissions
|
||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||
@@ -231,6 +232,7 @@ export const applicantRouter = router({
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
||||
roundId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -248,6 +250,9 @@ export const applicantRouter = router({
|
||||
id: input.projectId,
|
||||
submittedByUserId: ctx.user.id,
|
||||
},
|
||||
include: {
|
||||
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
@@ -257,8 +262,38 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Can't upload if already submitted
|
||||
if (project.submittedAt) {
|
||||
// Check round upload deadline policy if roundId provided
|
||||
let isLate = false
|
||||
const targetRoundId = input.roundId || project.roundId
|
||||
if (targetRoundId) {
|
||||
const round = input.roundId
|
||||
? await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { votingStartAt: true, settingsJson: true },
|
||||
})
|
||||
: project.round
|
||||
|
||||
if (round) {
|
||||
const settings = round.settingsJson as Record<string, unknown> | null
|
||||
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
|
||||
const now = new Date()
|
||||
const roundStarted = round.votingStartAt && now > round.votingStartAt
|
||||
|
||||
if (roundStarted && uploadPolicy === 'BLOCK') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Uploads are blocked after the round has started',
|
||||
})
|
||||
}
|
||||
|
||||
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
|
||||
isLate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Can't upload if already submitted (unless round allows it)
|
||||
if (project.submittedAt && !isLate) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Cannot modify a submitted project',
|
||||
@@ -275,6 +310,8 @@ export const applicantRouter = router({
|
||||
url,
|
||||
bucket: SUBMISSIONS_BUCKET,
|
||||
objectKey,
|
||||
isLate,
|
||||
roundId: targetRoundId,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -291,6 +328,8 @@ export const applicantRouter = router({
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
||||
bucket: z.string(),
|
||||
objectKey: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
isLate: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -317,13 +356,14 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const { projectId, ...fileData } = input
|
||||
const { projectId, roundId, isLate, ...fileData } = input
|
||||
|
||||
// Delete existing file of same type if exists
|
||||
// Delete existing file of same type, scoped by roundId if provided
|
||||
await ctx.prisma.projectFile.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
fileType: input.fileType,
|
||||
...(roundId ? { roundId } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -332,6 +372,8 @@ export const applicantRouter = router({
|
||||
data: {
|
||||
projectId,
|
||||
...fileData,
|
||||
roundId: roundId || null,
|
||||
isLate: isLate || false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -380,6 +422,48 @@ export const applicantRouter = router({
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get status timeline from ProjectStatusHistory
|
||||
*/
|
||||
getStatusTimeline: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Verify user has access to this project
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{
|
||||
teamMembers: {
|
||||
some: { userId: ctx.user.id },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Project not found',
|
||||
})
|
||||
}
|
||||
|
||||
const history = await ctx.prisma.projectStatusHistory.findMany({
|
||||
where: { projectId: input.projectId },
|
||||
orderBy: { changedAt: 'asc' },
|
||||
select: {
|
||||
status: true,
|
||||
changedAt: true,
|
||||
changedBy: true,
|
||||
},
|
||||
})
|
||||
|
||||
return history
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get submission status timeline
|
||||
*/
|
||||
@@ -412,6 +496,9 @@ export const applicantRouter = router({
|
||||
},
|
||||
},
|
||||
},
|
||||
wonAwards: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -425,40 +512,89 @@ export const applicantRouter = router({
|
||||
// Get the project status
|
||||
const currentStatus = project.status ?? 'SUBMITTED'
|
||||
|
||||
// Build timeline
|
||||
// Fetch actual status history
|
||||
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
|
||||
where: { projectId: input.projectId },
|
||||
orderBy: { changedAt: 'asc' },
|
||||
select: { status: true, changedAt: true },
|
||||
})
|
||||
|
||||
// Build a map of status -> earliest changedAt
|
||||
const statusDateMap = new Map<string, Date>()
|
||||
for (const entry of statusHistory) {
|
||||
if (!statusDateMap.has(entry.status)) {
|
||||
statusDateMap.set(entry.status, entry.changedAt)
|
||||
}
|
||||
}
|
||||
|
||||
const isRejected = currentStatus === 'REJECTED'
|
||||
const hasWonAward = project.wonAwards.length > 0
|
||||
|
||||
// Build timeline - handle REJECTED as terminal state
|
||||
const timeline = [
|
||||
{
|
||||
status: 'CREATED',
|
||||
label: 'Application Started',
|
||||
date: project.createdAt,
|
||||
completed: true,
|
||||
isTerminal: false,
|
||||
},
|
||||
{
|
||||
status: 'SUBMITTED',
|
||||
label: 'Application Submitted',
|
||||
date: project.submittedAt,
|
||||
completed: !!project.submittedAt,
|
||||
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
|
||||
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
|
||||
isTerminal: false,
|
||||
},
|
||||
{
|
||||
status: 'UNDER_REVIEW',
|
||||
label: 'Under Review',
|
||||
date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
|
||||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
|
||||
},
|
||||
{
|
||||
status: 'SEMIFINALIST',
|
||||
label: 'Semi-finalist',
|
||||
date: null, // Would need status change tracking
|
||||
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
|
||||
},
|
||||
{
|
||||
status: 'FINALIST',
|
||||
label: 'Finalist',
|
||||
date: null,
|
||||
completed: ['FINALIST', 'WINNER'].includes(currentStatus),
|
||||
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
|
||||
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
|
||||
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
|
||||
isTerminal: false,
|
||||
},
|
||||
]
|
||||
|
||||
if (isRejected) {
|
||||
// For rejected projects, show REJECTED as the terminal red step
|
||||
timeline.push({
|
||||
status: 'REJECTED',
|
||||
label: 'Not Selected',
|
||||
date: statusDateMap.get('REJECTED') || null,
|
||||
completed: true,
|
||||
isTerminal: true,
|
||||
})
|
||||
} else {
|
||||
// Normal progression
|
||||
timeline.push(
|
||||
{
|
||||
status: 'SEMIFINALIST',
|
||||
label: 'Semi-finalist',
|
||||
date: statusDateMap.get('SEMIFINALIST') || null,
|
||||
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
|
||||
isTerminal: false,
|
||||
},
|
||||
{
|
||||
status: 'FINALIST',
|
||||
label: 'Finalist',
|
||||
date: statusDateMap.get('FINALIST') || null,
|
||||
completed: currentStatus === 'FINALIST' || hasWonAward,
|
||||
isTerminal: false,
|
||||
},
|
||||
)
|
||||
|
||||
if (hasWonAward) {
|
||||
timeline.push({
|
||||
status: 'WINNER',
|
||||
label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
|
||||
date: null,
|
||||
completed: true,
|
||||
isTerminal: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
timeline,
|
||||
@@ -714,4 +850,130 @@ export const applicantRouter = router({
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send a message to the assigned mentor
|
||||
*/
|
||||
sendMentorMessage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
message: z.string().min(1).max(5000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify user is part of this project team
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{
|
||||
teamMembers: {
|
||||
some: { userId: ctx.user.id },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
mentorAssignment: { select: { mentorId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Project not found or you do not have access',
|
||||
})
|
||||
}
|
||||
|
||||
if (!project.mentorAssignment) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No mentor assigned to 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 the mentor
|
||||
await createNotification({
|
||||
userId: project.mentorAssignment.mentorId,
|
||||
type: 'MENTOR_MESSAGE',
|
||||
title: 'New Message',
|
||||
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
|
||||
linkUrl: `/mentor/projects/${input.projectId}`,
|
||||
linkLabel: 'View Message',
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
projectId: input.projectId,
|
||||
projectName: project.title,
|
||||
},
|
||||
})
|
||||
|
||||
return mentorMessage
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get mentor messages for a project (applicant side)
|
||||
*/
|
||||
getMentorMessages: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Verify user is part of this project team
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{
|
||||
teamMembers: {
|
||||
some: { userId: ctx.user.id },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Project not found or you do not have access',
|
||||
})
|
||||
}
|
||||
|
||||
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 mentor as read
|
||||
await ctx.prisma.mentorMessage.updateMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
senderId: { not: ctx.user.id },
|
||||
isRead: false,
|
||||
},
|
||||
data: { isRead: true },
|
||||
})
|
||||
|
||||
return messages
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
||||
import { processEvaluationReminders } from '../services/evaluation-reminders'
|
||||
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||
|
||||
export const evaluationRouter = router({
|
||||
/**
|
||||
@@ -89,7 +92,7 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
criterionScoresJson: z.record(z.number()).optional(),
|
||||
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
|
||||
globalScore: z.number().int().min(1).max(10).optional().nullable(),
|
||||
binaryDecision: z.boolean().optional().nullable(),
|
||||
feedbackText: z.string().optional().nullable(),
|
||||
@@ -134,7 +137,7 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
criterionScoresJson: z.record(z.number()),
|
||||
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
||||
globalScore: z.number().int().min(1).max(10),
|
||||
binaryDecision: z.boolean(),
|
||||
feedbackText: z.string().min(10),
|
||||
@@ -325,4 +328,297 @@ export const evaluationRouter = router({
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Conflict of Interest (COI) Endpoints
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Declare a conflict of interest for an assignment
|
||||
*/
|
||||
declareCOI: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
assignmentId: z.string(),
|
||||
hasConflict: z.boolean(),
|
||||
conflictType: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Look up the assignment to get projectId, roundId, userId
|
||||
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
||||
where: { id: input.assignmentId },
|
||||
include: {
|
||||
project: { select: { title: true } },
|
||||
round: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Verify ownership
|
||||
if (assignment.userId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
}
|
||||
|
||||
// Upsert COI record
|
||||
const coi = await ctx.prisma.conflictOfInterest.upsert({
|
||||
where: { assignmentId: input.assignmentId },
|
||||
create: {
|
||||
assignmentId: input.assignmentId,
|
||||
userId: ctx.user.id,
|
||||
projectId: assignment.projectId,
|
||||
roundId: assignment.roundId,
|
||||
hasConflict: input.hasConflict,
|
||||
conflictType: input.hasConflict ? input.conflictType : null,
|
||||
description: input.hasConflict ? input.description : null,
|
||||
},
|
||||
update: {
|
||||
hasConflict: input.hasConflict,
|
||||
conflictType: input.hasConflict ? input.conflictType : null,
|
||||
description: input.hasConflict ? input.description : null,
|
||||
declaredAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Notify admins if conflict declared
|
||||
if (input.hasConflict) {
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.JURY_INACTIVE,
|
||||
title: 'Conflict of Interest Declared',
|
||||
message: `${ctx.user.name || ctx.user.email} declared a conflict of interest (${input.conflictType || 'unspecified'}) for project "${assignment.project.title}" in ${assignment.round.name}.`,
|
||||
linkUrl: `/admin/rounds/${assignment.roundId}/coi`,
|
||||
linkLabel: 'Review COI',
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
assignmentId: input.assignmentId,
|
||||
userId: ctx.user.id,
|
||||
projectId: assignment.projectId,
|
||||
roundId: assignment.roundId,
|
||||
conflictType: input.conflictType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'COI_DECLARED',
|
||||
entityType: 'ConflictOfInterest',
|
||||
entityId: coi.id,
|
||||
detailsJson: {
|
||||
assignmentId: input.assignmentId,
|
||||
projectId: assignment.projectId,
|
||||
roundId: assignment.roundId,
|
||||
hasConflict: input.hasConflict,
|
||||
conflictType: input.conflictType,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return coi
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get COI status for an assignment
|
||||
*/
|
||||
getCOIStatus: protectedProcedure
|
||||
.input(z.object({ assignmentId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.conflictOfInterest.findUnique({
|
||||
where: { assignmentId: input.assignmentId },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List COI declarations for a round (admin only)
|
||||
*/
|
||||
listCOIByRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
hasConflictOnly: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
...(input.hasConflictOnly && { hasConflict: true }),
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
assignment: {
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
reviewedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { declaredAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Review a COI declaration (admin only)
|
||||
*/
|
||||
reviewCOI: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
reviewAction: z.enum(['cleared', 'reassigned', 'noted']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const coi = await ctx.prisma.conflictOfInterest.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
reviewedById: ctx.user.id,
|
||||
reviewedAt: new Date(),
|
||||
reviewAction: input.reviewAction,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'COI_REVIEWED',
|
||||
entityType: 'ConflictOfInterest',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
reviewAction: input.reviewAction,
|
||||
assignmentId: coi.assignmentId,
|
||||
userId: coi.userId,
|
||||
projectId: coi.projectId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return coi
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Reminder Triggers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Manually trigger reminder check for a specific round (admin only)
|
||||
*/
|
||||
triggerReminders: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await processEvaluationReminders(input.roundId)
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REMINDERS_TRIGGERED',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
sent: result.sent,
|
||||
errors: result.errors,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// AI Evaluation Summary Endpoints
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate an AI-powered evaluation summary for a project (admin only)
|
||||
*/
|
||||
generateSummary: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return generateSummary({
|
||||
projectId: input.projectId,
|
||||
roundId: input.roundId,
|
||||
userId: ctx.user.id,
|
||||
prisma: ctx.prisma,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get an existing evaluation summary for a project (admin only)
|
||||
*/
|
||||
getSummary: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.evaluationSummary.findUnique({
|
||||
where: {
|
||||
projectId_roundId: {
|
||||
projectId: input.projectId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Generate summaries for all projects in a round with submitted evaluations (admin only)
|
||||
*/
|
||||
generateBulkSummaries: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Find all projects in the round with at least 1 submitted evaluation
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
assignments: {
|
||||
some: {
|
||||
evaluation: {
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
let generated = 0
|
||||
const errors: Array<{ projectId: string; error: string }> = []
|
||||
|
||||
// Generate summaries sequentially to avoid rate limits
|
||||
for (const project of projects) {
|
||||
try {
|
||||
await generateSummary({
|
||||
projectId: project.id,
|
||||
roundId: input.roundId,
|
||||
userId: ctx.user.id,
|
||||
prisma: ctx.prisma,
|
||||
})
|
||||
generated++
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
projectId: project.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: projects.length,
|
||||
generated,
|
||||
errors,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -228,6 +228,103 @@ export const exportRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Export filtering results as CSV data
|
||||
*/
|
||||
filteringResults: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await ctx.prisma.filteringResult.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
title: true,
|
||||
teamName: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
oceanIssue: true,
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { project: { title: 'asc' } },
|
||||
})
|
||||
|
||||
// Collect all unique AI screening keys across all results
|
||||
const aiKeys = new Set<string>()
|
||||
results.forEach((r) => {
|
||||
if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') {
|
||||
const screening = r.aiScreeningJson as Record<string, Record<string, unknown>>
|
||||
for (const ruleResult of Object.values(screening)) {
|
||||
if (ruleResult && typeof ruleResult === 'object') {
|
||||
Object.keys(ruleResult).forEach((k) => aiKeys.add(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const sortedAiKeys = Array.from(aiKeys).sort()
|
||||
|
||||
const data = results.map((r) => {
|
||||
// Flatten AI screening - take first rule result's values
|
||||
const aiFlat: Record<string, unknown> = {}
|
||||
if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') {
|
||||
const screening = r.aiScreeningJson as Record<string, Record<string, unknown>>
|
||||
const firstEntry = Object.values(screening)[0]
|
||||
if (firstEntry && typeof firstEntry === 'object') {
|
||||
for (const key of sortedAiKeys) {
|
||||
const val = firstEntry[key]
|
||||
aiFlat[`ai_${key}`] = val !== undefined ? String(val) : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectTitle: r.project.title,
|
||||
teamName: r.project.teamName ?? '',
|
||||
category: r.project.competitionCategory ?? '',
|
||||
country: r.project.country ?? '',
|
||||
oceanIssue: r.project.oceanIssue ?? '',
|
||||
tags: r.project.tags.join(', '),
|
||||
outcome: r.outcome,
|
||||
finalOutcome: r.finalOutcome ?? '',
|
||||
overrideReason: r.overrideReason ?? '',
|
||||
...aiFlat,
|
||||
}
|
||||
})
|
||||
|
||||
// Build columns list
|
||||
const baseColumns = [
|
||||
'projectTitle',
|
||||
'teamName',
|
||||
'category',
|
||||
'country',
|
||||
'oceanIssue',
|
||||
'tags',
|
||||
'outcome',
|
||||
'finalOutcome',
|
||||
'overrideReason',
|
||||
]
|
||||
const aiColumns = sortedAiKeys.map((k) => `ai_${k}`)
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'FilteringResult',
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
columns: [...baseColumns, ...aiColumns],
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Export audit logs as CSV data
|
||||
*/
|
||||
|
||||
@@ -20,10 +20,14 @@ export const fileRouter = router({
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
// Find the file record to get the project
|
||||
// Find the file record to get the project and round info
|
||||
const file = await ctx.prisma.projectFile.findFirst({
|
||||
where: { bucket: input.bucket, objectKey: input.objectKey },
|
||||
select: { projectId: true },
|
||||
select: {
|
||||
projectId: true,
|
||||
roundId: true,
|
||||
round: { select: { programId: true, sortOrder: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
@@ -33,24 +37,55 @@ export const fileRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is assigned as jury or mentor for this project
|
||||
const [juryAssignment, mentorAssignment] = await Promise.all([
|
||||
// Check if user is assigned as jury, mentor, or team member for this project
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: file.projectId },
|
||||
select: { id: true },
|
||||
select: { id: true, roundId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: file.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this file',
|
||||
})
|
||||
}
|
||||
|
||||
// For jury members, verify round-scoped access:
|
||||
// File must belong to the jury's assigned round or a prior round in the same program
|
||||
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
|
||||
const assignedRound = await ctx.prisma.round.findUnique({
|
||||
where: { id: juryAssignment.roundId },
|
||||
select: { programId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
if (assignedRound) {
|
||||
const sameProgram = assignedRound.programId === file.round.programId
|
||||
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
|
||||
|
||||
if (!sameProgram || !priorOrSameRound) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this file',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
|
||||
@@ -189,12 +224,15 @@ export const fileRouter = router({
|
||||
* Checks that the user is authorized to view the project's files
|
||||
*/
|
||||
listByProject: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
const [juryAssignment, mentorAssignment] = await Promise.all([
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
@@ -203,9 +241,19 @@ export const fileRouter = router({
|
||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this project\'s files',
|
||||
@@ -213,9 +261,127 @@ export const fileRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { projectId: input.projectId }
|
||||
if (input.roundId) {
|
||||
where.roundId = input.roundId
|
||||
}
|
||||
|
||||
return ctx.prisma.projectFile.findMany({
|
||||
where: { projectId: input.projectId },
|
||||
where,
|
||||
include: {
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List files for a project grouped by round
|
||||
* Returns files for the specified round + all prior rounds in the same program
|
||||
*/
|
||||
listByProjectForRound: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true, roundId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this project\'s files',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get the target round with its program and sortOrder
|
||||
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { programId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
// Get all rounds in the same program with sortOrder <= target
|
||||
const eligibleRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
programId: targetRound.programId,
|
||||
sortOrder: { lte: targetRound.sortOrder },
|
||||
},
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
|
||||
|
||||
// Get files for these rounds (or files with no roundId)
|
||||
const files = await ctx.prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
OR: [
|
||||
{ roundId: { in: eligibleRoundIds } },
|
||||
{ roundId: null },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
})
|
||||
|
||||
// Group by round
|
||||
const grouped: Array<{
|
||||
roundId: string | null
|
||||
roundName: string
|
||||
sortOrder: number
|
||||
files: typeof files
|
||||
}> = []
|
||||
|
||||
// Add "General" group for files with no round
|
||||
const generalFiles = files.filter((f) => !f.roundId)
|
||||
if (generalFiles.length > 0) {
|
||||
grouped.push({
|
||||
roundId: null,
|
||||
roundName: 'General',
|
||||
sortOrder: -1,
|
||||
files: generalFiles,
|
||||
})
|
||||
}
|
||||
|
||||
// Add groups for each round
|
||||
for (const round of eligibleRounds) {
|
||||
const roundFiles = files.filter((f) => f.roundId === round.id)
|
||||
if (roundFiles.length > 0) {
|
||||
grouped.push({
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
sortOrder: round.sortOrder,
|
||||
files: roundFiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return grouped
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -635,6 +635,109 @@ export const mentorRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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: `/my-submission/${input.projectId}`,
|
||||
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
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all mentor assignments (admin)
|
||||
*/
|
||||
|
||||
@@ -370,6 +370,17 @@ export const projectRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Record status change in history
|
||||
if (status) {
|
||||
await ctx.prisma.projectStatusHistory.create({
|
||||
data: {
|
||||
projectId: id,
|
||||
status,
|
||||
changedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Send notifications if status changed
|
||||
if (status) {
|
||||
// Get round details for notification
|
||||
@@ -648,6 +659,17 @@ export const projectRouter = router({
|
||||
data: { status: input.status },
|
||||
})
|
||||
|
||||
// Record status change in history for each project
|
||||
if (matchingIds.length > 0) {
|
||||
await ctx.prisma.projectStatusHistory.createMany({
|
||||
data: matchingIds.map((projectId) => ({
|
||||
projectId,
|
||||
status: input.status,
|
||||
changedBy: ctx.user.id,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
|
||||
@@ -443,9 +443,25 @@ export const roundRouter = router({
|
||||
id: z.string(),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
scale: z.number().int().min(1).max(10),
|
||||
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).default('numeric'),
|
||||
// Numeric fields
|
||||
scale: z.number().int().min(1).max(10).optional(),
|
||||
weight: z.number().optional(),
|
||||
required: z.boolean(),
|
||||
required: z.boolean().optional(),
|
||||
// Text fields
|
||||
maxLength: z.number().int().min(1).max(10000).optional(),
|
||||
placeholder: z.string().optional(),
|
||||
// Boolean fields
|
||||
trueLabel: z.string().optional(),
|
||||
falseLabel: z.string().optional(),
|
||||
// Conditional visibility
|
||||
condition: z.object({
|
||||
criterionId: z.string(),
|
||||
operator: z.enum(['equals', 'greaterThan', 'lessThan']),
|
||||
value: z.union([z.number(), z.string(), z.boolean()]),
|
||||
}).optional(),
|
||||
// Section grouping
|
||||
sectionId: z.string().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user