feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
- processRoundClose EVALUATION uses ranking scores + advanceMode config (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED - Advancement emails generate invite tokens for passwordless users with "Create Your Account" CTA; rejection emails have no link - Finalization UI shows account stats (invite vs dashboard link counts) - Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson) - New award pool notification system: getAwardSelectionNotificationTemplate email, notifyEligibleProjects mutation with invite token generation, "Notify Pool" button on award detail page with custom message dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,13 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||||
import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
|
||||
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
|
||||
import { getImageUploadUrl, confirmImageUpload, getImageUrl, type ImageUploadConfig } from '@/server/utils/image-upload'
|
||||
import { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage'
|
||||
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
|
||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { createNotification } from '../services/in-app-notification'
|
||||
import { checkRequirementsAndTransition } from '../services/round-engine'
|
||||
import { EvaluationConfigSchema } from '@/types/competition-configs'
|
||||
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
||||
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
// Bucket for applicant submissions
|
||||
@@ -415,14 +415,17 @@ export const applicantRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-transition: if uploading against a round requirement, check completion
|
||||
if (roundId && requirementId) {
|
||||
await checkRequirementsAndTransition(
|
||||
projectId,
|
||||
roundId,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
// Auto-transition: mark as IN_PROGRESS on file activity, then check completion
|
||||
if (roundId) {
|
||||
await triggerInProgressOnActivity(projectId, roundId, ctx.user.id, ctx.prisma)
|
||||
if (requirementId) {
|
||||
await checkRequirementsAndTransition(
|
||||
projectId,
|
||||
roundId,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
||||
@@ -724,6 +727,8 @@ export const applicantRouter = router({
|
||||
email: true,
|
||||
status: true,
|
||||
lastLoginAt: true,
|
||||
profileImageKey: true,
|
||||
profileImageProvider: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -742,9 +747,20 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Generate presigned avatar URLs for team members with profile images
|
||||
const avatarUrls: Record<string, string> = {}
|
||||
for (const member of project.teamMembers) {
|
||||
if (member.user.profileImageKey) {
|
||||
const providerType = (member.user.profileImageProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
avatarUrls[member.userId] = await provider.getDownloadUrl(member.user.profileImageKey)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
teamMembers: project.teamMembers,
|
||||
submittedBy: project.submittedBy,
|
||||
avatarUrls,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1296,6 +1312,7 @@ export const applicantRouter = router({
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
roundType: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
})
|
||||
@@ -1311,6 +1328,24 @@ export const applicantRouter = router({
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// Check if there is an active intake round (applicants can edit project details during intake)
|
||||
const activeIntakeRound = await ctx.prisma.round.findFirst({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
roundType: 'INTAKE',
|
||||
status: 'ROUND_ACTIVE',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// Generate presigned logo URL if the project has a logo
|
||||
let logoUrl: string | null = null
|
||||
if (project.logoKey) {
|
||||
const providerType = (project.logoProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
logoUrl = await provider.getDownloadUrl(project.logoKey)
|
||||
}
|
||||
|
||||
return {
|
||||
project: {
|
||||
...project,
|
||||
@@ -1321,6 +1356,8 @@ export const applicantRouter = router({
|
||||
timeline,
|
||||
currentStatus,
|
||||
hasPassedIntake: !!passedIntake,
|
||||
isIntakeOpen: !!activeIntakeRound,
|
||||
logoUrl,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1430,7 +1467,7 @@ export const applicantRouter = router({
|
||||
type TimelineEntry = {
|
||||
id: string
|
||||
label: string
|
||||
roundType: 'EVALUATION' | 'GRAND_FINALE'
|
||||
roundType: string
|
||||
status: string
|
||||
windowOpenAt: Date | null
|
||||
windowCloseAt: Date | null
|
||||
@@ -1440,25 +1477,32 @@ export const applicantRouter = router({
|
||||
|
||||
const entries: TimelineEntry[] = []
|
||||
|
||||
// Build lookup for filtering rounds and their next evaluation round
|
||||
// Build lookup for filtering rounds
|
||||
const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING')
|
||||
const evalRounds = rounds.filter((r) => r.roundType === 'EVALUATION')
|
||||
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
|
||||
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
|
||||
|
||||
// Process EVALUATION rounds
|
||||
for (const evalRound of evalRounds) {
|
||||
const actualState = stateMap.get(evalRound.id) ?? null
|
||||
// Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always.
|
||||
// Also hide MENTORING unless the project is actually participating in it.
|
||||
const visibleRounds = rounds.filter(
|
||||
(r) => {
|
||||
if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false
|
||||
if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
// Check if a FILTERING round before this eval round rejected the project
|
||||
for (const round of visibleRounds) {
|
||||
const actualState = stateMap.get(round.id) ?? null
|
||||
|
||||
// Check if a FILTERING round before this round rejected the project
|
||||
let projectState = actualState
|
||||
let isSynthesizedRejection = false
|
||||
|
||||
// Find FILTERING rounds that come before this eval round in sortOrder
|
||||
const evalSortOrder = rounds.findIndex((r) => r.id === evalRound.id)
|
||||
const roundSortOrder = rounds.findIndex((r) => r.id === round.id)
|
||||
const precedingFilterRounds = filteringRounds.filter((fr) => {
|
||||
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
||||
return frIdx < evalSortOrder
|
||||
return frIdx < roundSortOrder
|
||||
})
|
||||
|
||||
for (const fr of precedingFilterRounds) {
|
||||
@@ -1475,12 +1519,12 @@ export const applicantRouter = router({
|
||||
}
|
||||
|
||||
entries.push({
|
||||
id: evalRound.id,
|
||||
label: evalRound.name,
|
||||
roundType: 'EVALUATION',
|
||||
status: evalRound.status,
|
||||
windowOpenAt: evalRound.windowOpenAt,
|
||||
windowCloseAt: evalRound.windowCloseAt,
|
||||
id: round.id,
|
||||
label: round.name,
|
||||
roundType: round.roundType,
|
||||
status: round.status,
|
||||
windowOpenAt: round.windowOpenAt,
|
||||
windowCloseAt: round.windowCloseAt,
|
||||
projectState,
|
||||
isSynthesizedRejection,
|
||||
})
|
||||
@@ -1913,6 +1957,59 @@ export const applicantRouter = router({
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete project logo (applicant access).
|
||||
*/
|
||||
deleteProjectLogo: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const isMember = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!isMember) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
|
||||
}
|
||||
|
||||
const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
|
||||
label: 'logo',
|
||||
generateKey: generateLogoKey,
|
||||
findCurrent: (prisma, entityId) =>
|
||||
prisma.project.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
}),
|
||||
getImageKey: (record) => record.logoKey,
|
||||
getProviderType: (record) =>
|
||||
(record.logoProvider as StorageProviderType) || 's3',
|
||||
setImage: (prisma, entityId, key, providerType) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: key, logoProvider: providerType },
|
||||
}),
|
||||
clearImage: (prisma, entityId) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: null, logoProvider: null },
|
||||
}),
|
||||
auditEntityType: 'Project',
|
||||
auditFieldName: 'logoKey',
|
||||
}
|
||||
|
||||
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get project logo URL (applicant access).
|
||||
*/
|
||||
@@ -1932,4 +2029,196 @@ export const applicantRouter = router({
|
||||
|
||||
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Withdraw from competition. Only team lead can withdraw.
|
||||
* Finds the current active (non-terminal) ProjectRoundState and transitions to WITHDRAWN.
|
||||
*/
|
||||
/**
|
||||
* Get mentoring request status for a project in a MENTORING round
|
||||
*/
|
||||
getMentoringRequestStatus: protectedProcedure
|
||||
.input(z.object({ projectId: z.string(), roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
|
||||
})
|
||||
|
||||
if (!round || round.roundType !== 'MENTORING') {
|
||||
return { available: false, requested: false, requestedAt: null, deadline: null, canStillRequest: false }
|
||||
}
|
||||
|
||||
const config = MentoringConfigSchema.safeParse(round.configJson)
|
||||
const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
|
||||
|
||||
const deadline = round.windowOpenAt
|
||||
? new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
|
||||
: null
|
||||
|
||||
const canStillRequest = round.status === 'ROUND_ACTIVE' && (!deadline || new Date() < deadline)
|
||||
|
||||
const prs = await ctx.prisma.projectRoundState.findUnique({
|
||||
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
|
||||
select: { metadataJson: true },
|
||||
})
|
||||
|
||||
const metadata = (prs?.metadataJson as Record<string, unknown>) ?? {}
|
||||
const requested = !!metadata.mentoringRequested
|
||||
const requestedAt = metadata.mentoringRequestedAt ? new Date(metadata.mentoringRequestedAt as string) : null
|
||||
|
||||
return { available: true, requested, requestedAt, deadline, canStillRequest }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Request or cancel mentoring for the current MENTORING round
|
||||
*/
|
||||
requestMentoring: protectedProcedure
|
||||
.input(z.object({ projectId: z.string(), roundId: z.string(), requesting: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can request mentoring' })
|
||||
}
|
||||
|
||||
// Verify caller is team lead
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true, submittedByUserId: true, title: true },
|
||||
})
|
||||
if (!project) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
if (project.submittedByUserId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can request mentoring' })
|
||||
}
|
||||
|
||||
// Verify round is MENTORING and ACTIVE
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
|
||||
})
|
||||
if (!round || round.roundType !== 'MENTORING') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a mentoring round' })
|
||||
}
|
||||
if (round.status !== 'ROUND_ACTIVE') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring round is not active' })
|
||||
}
|
||||
|
||||
// Check deadline
|
||||
const config = MentoringConfigSchema.safeParse(round.configJson)
|
||||
const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
|
||||
if (round.windowOpenAt) {
|
||||
const deadline = new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
|
||||
if (new Date() > deadline) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring request window has closed' })
|
||||
}
|
||||
}
|
||||
|
||||
// Find PRS
|
||||
const prs = await ctx.prisma.projectRoundState.findUnique({
|
||||
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
|
||||
})
|
||||
if (!prs) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project is not assigned to this round' })
|
||||
}
|
||||
|
||||
const existingMeta = (prs.metadataJson as Record<string, unknown>) ?? {}
|
||||
|
||||
// Update metadataJson with mentoring request info
|
||||
await ctx.prisma.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: {
|
||||
metadataJson: {
|
||||
...existingMeta,
|
||||
mentoringRequested: input.requesting,
|
||||
mentoringRequestedAt: input.requesting ? new Date().toISOString() : null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If requesting mentoring and currently PASSED (pass-through), transition to IN_PROGRESS
|
||||
if (input.requesting && prs.state === 'PASSED') {
|
||||
await transitionProject(
|
||||
input.projectId, input.roundId,
|
||||
'IN_PROGRESS' as Parameters<typeof transitionProject>[2],
|
||||
ctx.user.id, ctx.prisma,
|
||||
)
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
action: input.requesting ? 'MENTORING_REQUESTED' : 'MENTORING_CANCELLED',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
userId: ctx.user.id,
|
||||
detailsJson: { roundId: input.roundId, projectTitle: project.title },
|
||||
})
|
||||
|
||||
return { success: true, requesting: input.requesting }
|
||||
}),
|
||||
|
||||
withdrawFromCompetition: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can withdraw' })
|
||||
}
|
||||
|
||||
// Verify caller is team lead (submittedByUserId)
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true, submittedByUserId: true, title: true },
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
}
|
||||
|
||||
if (project.submittedByUserId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can withdraw from the competition' })
|
||||
}
|
||||
|
||||
// Find the active (non-terminal) ProjectRoundState
|
||||
const activePrs = await ctx.prisma.projectRoundState.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
round: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
|
||||
},
|
||||
include: { round: { select: { id: true, name: true } } },
|
||||
orderBy: { round: { sortOrder: 'desc' } },
|
||||
})
|
||||
|
||||
if (!activePrs || isTerminalState(activePrs.state as Parameters<typeof isTerminalState>[0])) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active round participation to withdraw from' })
|
||||
}
|
||||
|
||||
const result = await transitionProject(
|
||||
input.projectId,
|
||||
activePrs.roundId,
|
||||
'WITHDRAWN' as Parameters<typeof transitionProject>[2],
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to withdraw',
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
action: 'WITHDRAWAL',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
userId: ctx.user.id,
|
||||
detailsJson: { roundId: activePrs.roundId, roundName: activePrs.round.name, projectTitle: project.title },
|
||||
})
|
||||
|
||||
return { success: true, roundId: activePrs.roundId, roundName: activePrs.round.name }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
|
||||
|
||||
/**
|
||||
* Auto-trigger AI ranking if all required assignments for the round are complete.
|
||||
@@ -377,6 +378,12 @@ export const evaluationRouter = router({
|
||||
// Auto-trigger ranking if all assignments complete (fire-and-forget, never awaited)
|
||||
void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id)
|
||||
|
||||
// Auto-transition: mark project IN_PROGRESS and check if all evaluations are done
|
||||
const projectId = evaluation.assignment.projectId
|
||||
const roundIdForTransition = evaluation.assignment.roundId
|
||||
await triggerInProgressOnActivity(projectId, roundIdForTransition, ctx.user.id, ctx.prisma)
|
||||
await checkEvaluationCompletionAndTransition(projectId, roundIdForTransition, ctx.user.id, ctx.prisma)
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { triggerInProgressOnActivity } from '../services/round-engine'
|
||||
|
||||
/**
|
||||
* Verify the current session user exists in the database.
|
||||
@@ -204,6 +205,11 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-transition: mark all projects as IN_PROGRESS when filtering starts
|
||||
for (const pss of projectStates) {
|
||||
await triggerInProgressOnActivity(pss.projectId, roundId, userId, prisma)
|
||||
}
|
||||
|
||||
// Execute rules — upsert results per batch for streaming to the UI
|
||||
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress, async (batchResults) => {
|
||||
if (batchResults.length === 0) return
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
addFileComment as workspaceAddFileComment,
|
||||
promoteFile as workspacePromoteFile,
|
||||
} from '../services/mentor-workspace'
|
||||
import { triggerInProgressOnActivity } from '../services/round-engine'
|
||||
|
||||
export const mentorRouter = router({
|
||||
/**
|
||||
@@ -211,6 +212,23 @@ export const mentorRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
return assignment
|
||||
}),
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||
import { sendStyledNotificationEmail, getEmailPreviewHtml } from '@/lib/email'
|
||||
|
||||
export const messageRouter = router({
|
||||
/**
|
||||
@@ -12,7 +12,7 @@ export const messageRouter = router({
|
||||
send: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'PROGRAM_TEAM', 'ALL']),
|
||||
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
||||
recipientFilter: z.any().optional(),
|
||||
roundId: z.string().optional(),
|
||||
subject: z.string().min(1).max(500),
|
||||
@@ -371,6 +371,34 @@ export const messageRouter = router({
|
||||
|
||||
return template
|
||||
}),
|
||||
|
||||
/**
|
||||
* Preview styled email HTML for admin compose dialog.
|
||||
*/
|
||||
previewEmail: adminProcedure
|
||||
.input(z.object({ subject: z.string(), body: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return { html: getEmailPreviewHtml(input.subject, input.body) }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send a test email to the currently logged-in admin.
|
||||
*/
|
||||
sendTest: adminProcedure
|
||||
.input(z.object({ subject: z.string(), body: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await sendStyledNotificationEmail(
|
||||
ctx.user.email,
|
||||
ctx.user.name || '',
|
||||
'MESSAGE',
|
||||
{
|
||||
title: input.subject,
|
||||
message: input.body,
|
||||
linkUrl: '/admin/messages',
|
||||
}
|
||||
)
|
||||
return { sent: true, to: ctx.user.email }
|
||||
}),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -419,6 +447,35 @@ async function resolveRecipients(
|
||||
return assignments.map((a) => a.userId)
|
||||
}
|
||||
|
||||
case 'ROUND_APPLICANTS': {
|
||||
const targetRoundId = roundId || (filter?.roundId as string)
|
||||
if (!targetRoundId) return []
|
||||
// Get all projects in this round
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: { roundId: targetRoundId },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
if (projectIds.length === 0) return []
|
||||
// Get team members + submittedByUserId
|
||||
const [teamMembers, projects] = await Promise.all([
|
||||
prisma.teamMember.findMany({
|
||||
where: { projectId: { in: projectIds } },
|
||||
select: { userId: true },
|
||||
}),
|
||||
prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: { submittedByUserId: true },
|
||||
}),
|
||||
])
|
||||
const userIds = new Set<string>()
|
||||
for (const tm of teamMembers) userIds.add(tm.userId)
|
||||
for (const p of projects) {
|
||||
if (p.submittedByUserId) userIds.add(p.submittedByUserId)
|
||||
}
|
||||
return [...userIds]
|
||||
}
|
||||
|
||||
case 'PROGRAM_TEAM': {
|
||||
const programId = filter?.programId as string
|
||||
if (!programId) return []
|
||||
|
||||
@@ -2,72 +2,6 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { sendAnnouncementEmail } from '@/lib/email'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Send round-entry notification emails to project team members.
|
||||
* Fire-and-forget: errors are logged but never block the assignment.
|
||||
*/
|
||||
async function sendRoundEntryEmails(
|
||||
prisma: PrismaClient,
|
||||
projectIds: string[],
|
||||
roundName: string,
|
||||
) {
|
||||
try {
|
||||
// Fetch projects with team members' user emails + fallback submittedByEmail
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByEmail: true,
|
||||
teamMembers: {
|
||||
select: {
|
||||
user: { select: { email: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const emailPromises: Promise<void>[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
// Collect unique emails for this project
|
||||
const recipients = new Map<string, string | null>()
|
||||
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) {
|
||||
recipients.set(tm.user.email, tm.user.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no team members have emails, use submittedByEmail
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
}
|
||||
|
||||
for (const [email, name] of recipients) {
|
||||
emailPromises.push(
|
||||
sendAnnouncementEmail(
|
||||
email,
|
||||
name,
|
||||
`Your project has entered: ${roundName}`,
|
||||
`Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`,
|
||||
'View Your Dashboard',
|
||||
`${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`,
|
||||
).catch((err) => {
|
||||
console.error(`[round-entry-email] Failed to send to ${email}:`, err)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(emailPromises)
|
||||
} catch (err) {
|
||||
console.error('[round-entry-email] Failed to send round entry emails:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project Pool Router
|
||||
@@ -199,7 +133,7 @@ export const projectPoolRouter = router({
|
||||
assignToRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()).min(1).max(200),
|
||||
projectIds: z.array(z.string()).min(1).max(1000),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
@@ -228,10 +162,10 @@ export const projectPoolRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Verify round exists and get config
|
||||
// Verify round exists
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true, configJson: true },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
// Step 2: Perform bulk assignment in a transaction
|
||||
@@ -284,12 +218,6 @@ export const projectPoolRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Send round-entry notification emails if enabled (fire-and-forget)
|
||||
const config = (round.configJson as Record<string, unknown>) || {}
|
||||
if (config.notifyOnEntry) {
|
||||
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
assignedCount: result.count,
|
||||
@@ -313,10 +241,10 @@ export const projectPoolRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { programId, roundId, competitionCategory, unassignedOnly } = input
|
||||
|
||||
// Verify round exists and get config
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
// Verify round exists
|
||||
await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true, configJson: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// Find projects to assign
|
||||
@@ -388,12 +316,213 @@ export const projectPoolRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Send round-entry notification emails if enabled (fire-and-forget)
|
||||
const config = (round.configJson as Record<string, unknown>) || {}
|
||||
if (config.notifyOnEntry) {
|
||||
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
|
||||
}
|
||||
|
||||
return { success: true, assignedCount: result.count, roundId }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List projects in a specific round (for import-from-round picker).
|
||||
*/
|
||||
getProjectsInRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
states: z.array(z.string()).optional(),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { roundId, states, search } = input
|
||||
|
||||
const where: Record<string, unknown> = { roundId }
|
||||
|
||||
if (states && states.length > 0) {
|
||||
where.state = { in: states }
|
||||
}
|
||||
|
||||
if (search?.trim()) {
|
||||
where.project = {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where,
|
||||
select: {
|
||||
projectId: true,
|
||||
state: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { project: { title: 'asc' } },
|
||||
})
|
||||
|
||||
return projectStates.map((ps) => ({
|
||||
id: ps.project.id,
|
||||
title: ps.project.title,
|
||||
teamName: ps.project.teamName,
|
||||
competitionCategory: ps.project.competitionCategory,
|
||||
country: ps.project.country,
|
||||
state: ps.state,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Import projects from an earlier round into a later round.
|
||||
* Fills intermediate rounds with COMPLETED states to keep history clean.
|
||||
*/
|
||||
importFromRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sourceRoundId: z.string(),
|
||||
targetRoundId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(1000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { sourceRoundId, targetRoundId, projectIds } = input
|
||||
|
||||
// Validate both rounds exist and belong to the same competition
|
||||
const [sourceRound, targetRound] = await Promise.all([
|
||||
ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: sourceRoundId },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true },
|
||||
}),
|
||||
ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: targetRoundId },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (sourceRound.competitionId !== targetRound.competitionId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Source and target rounds must belong to the same competition',
|
||||
})
|
||||
}
|
||||
|
||||
if (sourceRound.sortOrder >= targetRound.sortOrder) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Source round must come before target round',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate all projectIds exist in the source round
|
||||
const sourceStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId: sourceRoundId, projectId: { in: projectIds } },
|
||||
select: { projectId: true, state: true },
|
||||
})
|
||||
|
||||
if (sourceStates.length !== projectIds.length) {
|
||||
const foundIds = new Set(sourceStates.map((s) => s.projectId))
|
||||
const missing = projectIds.filter((id) => !foundIds.has(id))
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${missing.length} project(s) not found in source round`,
|
||||
})
|
||||
}
|
||||
|
||||
// Find intermediate rounds
|
||||
const intermediateRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competitionId: sourceRound.competitionId,
|
||||
sortOrder: { gt: sourceRound.sortOrder, lt: targetRound.sortOrder },
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
// Check which projects are already in the target round
|
||||
const existingInTarget = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId: targetRoundId, projectId: { in: projectIds } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const alreadyInTarget = new Set(existingInTarget.map((e) => e.projectId))
|
||||
const toImport = projectIds.filter((id) => !alreadyInTarget.has(id))
|
||||
|
||||
if (toImport.length === 0) {
|
||||
return { imported: 0, skipped: projectIds.length }
|
||||
}
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
// Update source round states to COMPLETED (if PASSED or PENDING)
|
||||
await tx.projectRoundState.updateMany({
|
||||
where: {
|
||||
roundId: sourceRoundId,
|
||||
projectId: { in: toImport },
|
||||
state: { in: ['PASSED', 'PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
data: { state: 'COMPLETED' },
|
||||
})
|
||||
|
||||
// Create COMPLETED states for intermediate rounds
|
||||
if (intermediateRounds.length > 0) {
|
||||
const intermediateData = intermediateRounds.flatMap((round) =>
|
||||
toImport.map((projectId) => ({
|
||||
projectId,
|
||||
roundId: round.id,
|
||||
state: 'COMPLETED' as const,
|
||||
}))
|
||||
)
|
||||
await tx.projectRoundState.createMany({
|
||||
data: intermediateData,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Create PENDING states in the target round
|
||||
await tx.projectRoundState.createMany({
|
||||
data: toImport.map((projectId) => ({
|
||||
projectId,
|
||||
roundId: targetRoundId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
// Update project status to ASSIGNED
|
||||
await tx.project.updateMany({
|
||||
where: { id: { in: toImport } },
|
||||
data: { status: 'ASSIGNED' },
|
||||
})
|
||||
|
||||
// Create status history records
|
||||
await tx.projectStatusHistory.createMany({
|
||||
data: toImport.map((projectId) => ({
|
||||
projectId,
|
||||
status: 'ASSIGNED',
|
||||
changedBy: ctx.user?.id,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user?.id,
|
||||
action: 'IMPORT_FROM_ROUND',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
sourceRoundId,
|
||||
sourceRoundName: sourceRound.name,
|
||||
targetRoundId,
|
||||
targetRoundName: targetRound.name,
|
||||
importedCount: toImport.length,
|
||||
skippedCount: alreadyInTarget.size,
|
||||
intermediateRounds: intermediateRounds.length,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { imported: toImport.length, skipped: alreadyInTarget.size }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -6,7 +6,14 @@ import { logAudit } from '@/server/utils/audit'
|
||||
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||||
import { generateShortlist } from '../services/ai-shortlist'
|
||||
import { createBulkNotifications } from '../services/in-app-notification'
|
||||
import { sendAnnouncementEmail } from '@/lib/email'
|
||||
import {
|
||||
getAdvancementNotificationTemplate,
|
||||
getRejectionNotificationTemplate,
|
||||
sendStyledNotificationEmail,
|
||||
sendInvitationEmail,
|
||||
getBaseUrl,
|
||||
} from '@/lib/email'
|
||||
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
|
||||
import {
|
||||
openWindow,
|
||||
closeWindow,
|
||||
@@ -417,95 +424,6 @@ export const roundRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Fix 5: notifyOnEntry — notify team members when projects enter target round
|
||||
try {
|
||||
const targetConfig = (targetRound.configJson as Record<string, unknown>) || {}
|
||||
if (targetConfig.notifyOnEntry) {
|
||||
const teamMembers = await ctx.prisma.teamMember.findMany({
|
||||
where: { projectId: { in: idsToAdvance! } },
|
||||
select: { userId: true },
|
||||
})
|
||||
const userIds = [...new Set(teamMembers.map((tm) => tm.userId))]
|
||||
if (userIds.length > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds,
|
||||
type: 'round_entry',
|
||||
title: `Projects entered: ${targetRound.name}`,
|
||||
message: `Your project has been advanced to the round "${targetRound.name}".`,
|
||||
linkUrl: '/dashboard',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'ArrowRight',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[advanceProjects] notifyOnEntry notification failed (non-fatal):', notifyErr)
|
||||
}
|
||||
|
||||
// Fix 6: notifyOnAdvance — notify applicants from source round that projects advanced
|
||||
try {
|
||||
const sourceConfig = (currentRound.configJson as Record<string, unknown>) || {}
|
||||
if (sourceConfig.notifyOnAdvance) {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: idsToAdvance! } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByEmail: true,
|
||||
teamMembers: {
|
||||
select: { user: { select: { id: true, email: true, name: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Collect unique user IDs for in-app notifications
|
||||
const applicantUserIds = new Set<string>()
|
||||
for (const project of projects) {
|
||||
for (const tm of project.teamMembers) {
|
||||
applicantUserIds.add(tm.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (applicantUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds: [...applicantUserIds],
|
||||
type: 'project_advanced',
|
||||
title: 'Your project has advanced!',
|
||||
message: `Congratulations! Your project has advanced from "${currentRound.name}" to "${targetRound.name}".`,
|
||||
linkUrl: '/dashboard',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'Trophy',
|
||||
priority: 'high',
|
||||
})
|
||||
}
|
||||
|
||||
// Send emails to team members (fire-and-forget)
|
||||
for (const project of projects) {
|
||||
const recipients = new Map<string, string | null>()
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) recipients.set(tm.user.email, tm.user.name)
|
||||
}
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
}
|
||||
for (const [email, name] of recipients) {
|
||||
void sendAnnouncementEmail(
|
||||
email,
|
||||
name,
|
||||
`Your project has advanced to: ${targetRound.name}`,
|
||||
`Congratulations! Your project "${project.title}" has advanced from "${currentRound.name}" to "${targetRound.name}" in the Monaco Ocean Protection Challenge.`,
|
||||
'View Your Dashboard',
|
||||
`${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`,
|
||||
).catch((err) => {
|
||||
console.error(`[advanceProjects] notifyOnAdvance email failed for ${email}:`, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[advanceProjects] notifyOnAdvance notification failed (non-fatal):', notifyErr)
|
||||
}
|
||||
|
||||
return {
|
||||
advancedCount: idsToAdvance!.length,
|
||||
autoPassedCount,
|
||||
@@ -883,4 +801,477 @@ export const roundRouter = router({
|
||||
})
|
||||
return round ?? null
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Notification Procedures
|
||||
// =========================================================================
|
||||
|
||||
previewAdvancementEmail: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
targetRoundId: z.string().optional(),
|
||||
customMessage: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { roundId, targetRoundId, customMessage } = input
|
||||
|
||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } },
|
||||
})
|
||||
|
||||
// Determine target round name
|
||||
const rounds = currentRound.competition.rounds
|
||||
const currentIdx = rounds.findIndex((r) => r.id === roundId)
|
||||
const targetRound = targetRoundId
|
||||
? rounds.find((r) => r.id === targetRoundId)
|
||||
: rounds[currentIdx + 1]
|
||||
const toRoundName = targetRound?.name ?? 'Next Round'
|
||||
|
||||
// Count recipients: team members of PASSED or COMPLETED projects in this round
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
|
||||
let recipientCount = 0
|
||||
if (projectIds.length > 0) {
|
||||
const teamMembers = await ctx.prisma.teamMember.findMany({
|
||||
where: { projectId: { in: projectIds } },
|
||||
select: { user: { select: { email: true } } },
|
||||
})
|
||||
const emails = new Set(teamMembers.map((tm) => tm.user.email).filter(Boolean))
|
||||
|
||||
// Also count submittedByEmail for projects without team member emails
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } },
|
||||
})
|
||||
for (const p of projects) {
|
||||
const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email)
|
||||
if (!hasTeamEmail && p.submittedByEmail) {
|
||||
emails.add(p.submittedByEmail)
|
||||
}
|
||||
}
|
||||
recipientCount = emails.size
|
||||
}
|
||||
|
||||
// Build preview HTML
|
||||
const template = getAdvancementNotificationTemplate(
|
||||
'Team Member',
|
||||
'Your Project',
|
||||
currentRound.name,
|
||||
toRoundName,
|
||||
customMessage || undefined
|
||||
)
|
||||
|
||||
return { html: template.html, subject: template.subject, recipientCount }
|
||||
}),
|
||||
|
||||
sendAdvancementNotifications: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
targetRoundId: z.string().optional(),
|
||||
customMessage: z.string().optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, targetRoundId, customMessage } = input
|
||||
|
||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } },
|
||||
})
|
||||
|
||||
const rounds = currentRound.competition.rounds
|
||||
const currentIdx = rounds.findIndex((r) => r.id === roundId)
|
||||
const targetRound = targetRoundId
|
||||
? rounds.find((r) => r.id === targetRoundId)
|
||||
: rounds[currentIdx + 1]
|
||||
const toRoundName = targetRound?.name ?? 'Next Round'
|
||||
|
||||
// Get target projects
|
||||
let projectIds = input.projectIds
|
||||
if (!projectIds) {
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
projectIds = projectStates.map((ps) => ps.projectId)
|
||||
}
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return { sent: 0, failed: 0 }
|
||||
}
|
||||
|
||||
// Fetch projects with team members
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByEmail: true,
|
||||
teamMembers: {
|
||||
select: { user: { select: { id: true, email: true, name: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
const allUserIds = new Set<string>()
|
||||
|
||||
for (const project of projects) {
|
||||
const recipients = new Map<string, string | null>()
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) {
|
||||
recipients.set(tm.user.email, tm.user.name)
|
||||
allUserIds.add(tm.user.id)
|
||||
}
|
||||
}
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
}
|
||||
|
||||
for (const [email, name] of recipients) {
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
email,
|
||||
name || '',
|
||||
'ADVANCEMENT_NOTIFICATION',
|
||||
{
|
||||
title: 'Your project has advanced!',
|
||||
message: '',
|
||||
linkUrl: '/applicant',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
fromRoundName: currentRound.name,
|
||||
toRoundName,
|
||||
customMessage: customMessage || undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
sent++
|
||||
} catch (err) {
|
||||
console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create in-app notifications
|
||||
if (allUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds: [...allUserIds],
|
||||
type: 'project_advanced',
|
||||
title: 'Your project has advanced!',
|
||||
message: `Your project has advanced from "${currentRound.name}" to "${toRoundName}".`,
|
||||
linkUrl: '/applicant',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'Trophy',
|
||||
priority: 'high',
|
||||
})
|
||||
}
|
||||
|
||||
// Audit
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user?.id,
|
||||
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { sent, failed }
|
||||
}),
|
||||
|
||||
previewRejectionEmail: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
customMessage: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { roundId, customMessage } = input
|
||||
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
// Count recipients: team members of REJECTED projects
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'REJECTED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
|
||||
let recipientCount = 0
|
||||
if (projectIds.length > 0) {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } },
|
||||
})
|
||||
const emails = new Set<string>()
|
||||
for (const p of projects) {
|
||||
const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email)
|
||||
if (hasTeamEmail) {
|
||||
for (const tm of p.teamMembers) {
|
||||
if (tm.user.email) emails.add(tm.user.email)
|
||||
}
|
||||
} else if (p.submittedByEmail) {
|
||||
emails.add(p.submittedByEmail)
|
||||
}
|
||||
}
|
||||
recipientCount = emails.size
|
||||
}
|
||||
|
||||
const template = getRejectionNotificationTemplate(
|
||||
'Team Member',
|
||||
'Your Project',
|
||||
round.name,
|
||||
customMessage || undefined
|
||||
)
|
||||
|
||||
return { html: template.html, subject: template.subject, recipientCount }
|
||||
}),
|
||||
|
||||
sendRejectionNotifications: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
customMessage: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, customMessage } = input
|
||||
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'REJECTED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return { sent: 0, failed: 0 }
|
||||
}
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByEmail: true,
|
||||
teamMembers: {
|
||||
select: { user: { select: { id: true, email: true, name: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
const allUserIds = new Set<string>()
|
||||
|
||||
for (const project of projects) {
|
||||
const recipients = new Map<string, string | null>()
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) {
|
||||
recipients.set(tm.user.email, tm.user.name)
|
||||
allUserIds.add(tm.user.id)
|
||||
}
|
||||
}
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
}
|
||||
|
||||
for (const [email, name] of recipients) {
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
email,
|
||||
name || '',
|
||||
'REJECTION_NOTIFICATION',
|
||||
{
|
||||
title: 'Update on your application',
|
||||
message: '',
|
||||
linkUrl: '/applicant',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
roundName: round.name,
|
||||
customMessage: customMessage || undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
sent++
|
||||
} catch (err) {
|
||||
console.error(`[sendRejectionNotifications] Failed for ${email}:`, err)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In-app notifications
|
||||
if (allUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds: [...allUserIds],
|
||||
type: 'NOT_SELECTED',
|
||||
title: 'Update on your application',
|
||||
message: `Your project was not selected to advance from "${round.name}".`,
|
||||
linkUrl: '/applicant',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'Info',
|
||||
})
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user?.id,
|
||||
action: 'SEND_REJECTION_NOTIFICATIONS',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { sent, failed }
|
||||
}),
|
||||
|
||||
getBulkInvitePreview: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { roundId } = input
|
||||
|
||||
// Get all projects in this round
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return { uninvitedCount: 0, totalTeamMembers: 0, alreadyInvitedCount: 0 }
|
||||
}
|
||||
|
||||
// Get all team members for these projects
|
||||
const teamMembers = await ctx.prisma.teamMember.findMany({
|
||||
where: { projectId: { in: projectIds } },
|
||||
select: { user: { select: { id: true, status: true } } },
|
||||
})
|
||||
|
||||
// Deduplicate by user ID
|
||||
const userMap = new Map<string, string>()
|
||||
for (const tm of teamMembers) {
|
||||
userMap.set(tm.user.id, tm.user.status)
|
||||
}
|
||||
|
||||
let uninvitedCount = 0
|
||||
let alreadyInvitedCount = 0
|
||||
for (const [, status] of userMap) {
|
||||
if (status === 'ACTIVE' || status === 'INVITED') {
|
||||
alreadyInvitedCount++
|
||||
} else {
|
||||
uninvitedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uninvitedCount,
|
||||
totalTeamMembers: userMap.size,
|
||||
alreadyInvitedCount,
|
||||
}
|
||||
}),
|
||||
|
||||
bulkInviteTeamMembers: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId } = input
|
||||
|
||||
// Get all projects in this round
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return { invited: 0, skipped: 0, failed: 0 }
|
||||
}
|
||||
|
||||
// Get all team members with user details
|
||||
const teamMembers = await ctx.prisma.teamMember.findMany({
|
||||
where: { projectId: { in: projectIds } },
|
||||
select: {
|
||||
user: { select: { id: true, email: true, name: true, status: true, role: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Deduplicate by user ID
|
||||
const users = new Map<string, { id: string; email: string; name: string | null; status: string; role: string }>()
|
||||
for (const tm of teamMembers) {
|
||||
if (tm.user.email && !users.has(tm.user.id)) {
|
||||
users.set(tm.user.id, tm.user)
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
|
||||
const expiryMs = expiryHours * 60 * 60 * 1000
|
||||
|
||||
let invited = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
|
||||
for (const [, user] of users) {
|
||||
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||
},
|
||||
})
|
||||
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
||||
invited++
|
||||
} catch (err) {
|
||||
console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user?.id,
|
||||
action: 'BULK_INVITE_TEAM_MEMBERS',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
detailsJson: { invited, skipped, failed, totalUsers: users.size },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { invited, skipped, failed }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
getProjectRoundStates,
|
||||
getProjectRoundState,
|
||||
} from '../services/round-engine'
|
||||
import {
|
||||
processRoundClose,
|
||||
getFinalizationSummary,
|
||||
confirmFinalization,
|
||||
} from '../services/round-finalization'
|
||||
|
||||
const projectRoundStateEnum = z.enum([
|
||||
'PENDING',
|
||||
@@ -318,4 +323,110 @@ export const roundEngineRouter = router({
|
||||
projectIds: result.projectIds,
|
||||
}
|
||||
}),
|
||||
|
||||
// ─── Finalization Procedures ────────────────────────────────────────────
|
||||
|
||||
getFinalizationSummary: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getFinalizationSummary(input.roundId, ctx.prisma)
|
||||
}),
|
||||
|
||||
updateProposedOutcome: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
projectId: z.string(),
|
||||
proposedOutcome: projectRoundStateEnum,
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const prs = await ctx.prisma.projectRoundState.findUnique({
|
||||
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
|
||||
})
|
||||
if (!prs) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project round state not found' })
|
||||
|
||||
return ctx.prisma.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: { proposedOutcome: input.proposedOutcome },
|
||||
})
|
||||
}),
|
||||
|
||||
batchUpdateProposedOutcomes: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
outcomes: z.record(z.string(), projectRoundStateEnum),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let updated = 0
|
||||
for (const [projectId, outcome] of Object.entries(input.outcomes)) {
|
||||
await ctx.prisma.projectRoundState.updateMany({
|
||||
where: { projectId, roundId: input.roundId },
|
||||
data: { proposedOutcome: outcome },
|
||||
})
|
||||
updated++
|
||||
}
|
||||
return { updated }
|
||||
}),
|
||||
|
||||
confirmFinalization: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
targetRoundId: z.string().optional(),
|
||||
advancementMessage: z.string().optional(),
|
||||
rejectionMessage: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return confirmFinalization(
|
||||
input.roundId,
|
||||
{
|
||||
targetRoundId: input.targetRoundId,
|
||||
advancementMessage: input.advancementMessage,
|
||||
rejectionMessage: input.rejectionMessage,
|
||||
},
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
|
||||
endGracePeriod: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId } })
|
||||
|
||||
if (round.status !== 'ROUND_CLOSED') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round must be ROUND_CLOSED' })
|
||||
}
|
||||
if (!round.gracePeriodEndsAt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round has no grace period set' })
|
||||
}
|
||||
|
||||
// Clear grace period and process
|
||||
await ctx.prisma.round.update({
|
||||
where: { id: input.roundId },
|
||||
data: { gracePeriodEndsAt: new Date() },
|
||||
})
|
||||
|
||||
const result = await processRoundClose(input.roundId, ctx.user.id, ctx.prisma)
|
||||
return { processed: result.processed }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Manually trigger processRoundClose for already-closed rounds.
|
||||
* Used when a round was closed before the finalization system existed,
|
||||
* or when processRoundClose failed silently on close.
|
||||
*/
|
||||
processRoundProjects: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId } })
|
||||
|
||||
if (round.status !== 'ROUND_CLOSED' && round.status !== 'ROUND_ARCHIVED') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Round must be ROUND_CLOSED or ROUND_ARCHIVED, got ${round.status}`,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await processRoundClose(input.roundId, ctx.user.id, ctx.prisma)
|
||||
return { processed: result.processed }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
/**
|
||||
@@ -1182,6 +1184,181 @@ export const specialAwardRouter = router({
|
||||
return round
|
||||
}),
|
||||
|
||||
// ─── Pool Notifications ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get account stats for eligible projects (how many need invite vs have account)
|
||||
*/
|
||||
getNotificationStats: adminProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId: input.awardId, eligible: true },
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
submittedBy: { select: { id: true, passwordHash: true } },
|
||||
teamMembers: {
|
||||
select: { user: { select: { id: true, passwordHash: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const seen = new Set<string>()
|
||||
let needsInvite = 0
|
||||
let hasAccount = 0
|
||||
|
||||
for (const e of eligibilities) {
|
||||
const submitter = e.project.submittedBy
|
||||
if (submitter && !seen.has(submitter.id)) {
|
||||
seen.add(submitter.id)
|
||||
if (submitter.passwordHash) hasAccount++
|
||||
else needsInvite++
|
||||
}
|
||||
for (const tm of e.project.teamMembers) {
|
||||
if (!seen.has(tm.user.id)) {
|
||||
seen.add(tm.user.id)
|
||||
if (tm.user.passwordHash) hasAccount++
|
||||
else needsInvite++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { needsInvite, hasAccount, totalProjects: eligibilities.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Notify eligible projects that they've been selected for an award.
|
||||
* Generates invite tokens for passwordless users.
|
||||
*/
|
||||
notifyEligibleProjects: adminProcedure
|
||||
.input(z.object({
|
||||
awardId: z.string(),
|
||||
customMessage: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
select: { id: true, name: true, description: true, status: true },
|
||||
})
|
||||
|
||||
// Get eligible projects with submitter + team members
|
||||
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId: input.awardId, eligible: true },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedBy: {
|
||||
select: { id: true, email: true, name: true, passwordHash: true },
|
||||
},
|
||||
teamMembers: {
|
||||
select: {
|
||||
user: {
|
||||
select: { id: true, email: true, name: true, passwordHash: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (eligibilities.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No eligible projects to notify',
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-generate invite tokens for passwordless users
|
||||
const expiryMs = await getInviteExpiryMs(ctx.prisma)
|
||||
const expiresAt = new Date(Date.now() + expiryMs)
|
||||
const tokenMap = new Map<string, string>() // userId -> token
|
||||
|
||||
const allUsers: Array<{ id: string; passwordHash: string | null }> = []
|
||||
for (const e of eligibilities) {
|
||||
if (e.project.submittedBy) allUsers.push(e.project.submittedBy)
|
||||
for (const tm of e.project.teamMembers) allUsers.push(tm.user)
|
||||
}
|
||||
|
||||
const passwordlessUsers = allUsers.filter((u) => !u.passwordHash)
|
||||
const uniquePasswordless = [...new Map(passwordlessUsers.map((u) => [u.id, u])).values()]
|
||||
|
||||
for (const user of uniquePasswordless) {
|
||||
const token = generateInviteToken()
|
||||
tokenMap.set(user.id, token)
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt },
|
||||
})
|
||||
}
|
||||
|
||||
// Send emails
|
||||
let emailsSent = 0
|
||||
let emailsFailed = 0
|
||||
|
||||
for (const e of eligibilities) {
|
||||
const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: string | null }> = []
|
||||
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
|
||||
for (const tm of e.project.teamMembers) {
|
||||
if (!recipients.some((r) => r.id === tm.user.id)) {
|
||||
recipients.push(tm.user)
|
||||
}
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const token = tokenMap.get(recipient.id)
|
||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
recipient.email,
|
||||
recipient.name || '',
|
||||
'AWARD_SELECTION_NOTIFICATION',
|
||||
{
|
||||
title: `Selected for ${award.name}`,
|
||||
message: input.customMessage || '',
|
||||
metadata: {
|
||||
projectName: e.project.title,
|
||||
awardName: award.name,
|
||||
customMessage: input.customMessage,
|
||||
accountUrl,
|
||||
},
|
||||
},
|
||||
)
|
||||
emailsSent++
|
||||
} catch (err) {
|
||||
console.error(`[award-notify] Failed to email ${recipient.email}:`, err)
|
||||
emailsFailed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'NOTIFY_ELIGIBLE_PROJECTS',
|
||||
eligibleCount: eligibilities.length,
|
||||
emailsSent,
|
||||
emailsFailed,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { notified: eligibilities.length, emailsSent, emailsFailed }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete an award round (only if DRAFT)
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import crypto from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
@@ -8,29 +7,7 @@ import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
|
||||
import { hashPassword, validatePassword } from '@/lib/password'
|
||||
import { attachAvatarUrls } from '@/server/utils/avatar-url'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
const DEFAULT_INVITE_EXPIRY_HOURS = 72 // 3 days
|
||||
|
||||
async function getInviteExpiryHours(prisma: import('@prisma/client').PrismaClient): Promise<number> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'invite_link_expiry_hours' },
|
||||
select: { value: true },
|
||||
})
|
||||
const hours = setting?.value ? parseInt(setting.value, 10) : DEFAULT_INVITE_EXPIRY_HOURS
|
||||
return isNaN(hours) || hours < 1 ? DEFAULT_INVITE_EXPIRY_HOURS : hours
|
||||
} catch {
|
||||
return DEFAULT_INVITE_EXPIRY_HOURS
|
||||
}
|
||||
}
|
||||
|
||||
async function getInviteExpiryMs(prisma: import('@prisma/client').PrismaClient): Promise<number> {
|
||||
return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
function generateInviteToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
|
||||
|
||||
export const userRouter = router({
|
||||
/**
|
||||
@@ -95,9 +72,21 @@ export const userRouter = router({
|
||||
return { valid: false, error: 'EXPIRED_TOKEN' as const }
|
||||
}
|
||||
|
||||
// Check if user belongs to a team (was invited as team member)
|
||||
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
role: true,
|
||||
project: { select: { title: true, teamName: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
user: { name: user.name, email: user.email, role: user.role },
|
||||
team: teamMembership
|
||||
? { projectTitle: teamMembership.project.title, teamName: teamMembership.project.teamName }
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
Prisma,
|
||||
} from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { triggerInProgressOnActivity } from './round-engine'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -186,7 +187,7 @@ export async function submitVote(
|
||||
|
||||
const runoffRound = params.runoffRound ?? 0
|
||||
|
||||
return prisma.deliberationVote.upsert({
|
||||
const vote = await prisma.deliberationVote.upsert({
|
||||
where: {
|
||||
sessionId_juryMemberId_projectId_runoffRound: {
|
||||
sessionId: params.sessionId,
|
||||
@@ -208,6 +209,17 @@ export async function submitVote(
|
||||
isWinnerPick: params.isWinnerPick ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-transition: mark project IN_PROGRESS in the deliberation round
|
||||
try {
|
||||
if (session.roundId) {
|
||||
await triggerInProgressOnActivity(params.projectId, session.roundId, params.juryMemberId, prisma)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Deliberation] triggerInProgressOnActivity failed (non-fatal):', e)
|
||||
}
|
||||
|
||||
return vote
|
||||
}
|
||||
|
||||
// ─── Aggregation ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { PrismaClient, ProjectRoundStateValue, Prisma } from '@prisma/clien
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { safeValidateRoundConfig } from '@/types/competition-configs'
|
||||
import { expireIntentsForRound } from './assignment-intent'
|
||||
import { processRoundClose } from './round-finalization'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,11 +56,11 @@ const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
||||
}
|
||||
|
||||
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
||||
PENDING: ['IN_PROGRESS', 'PASSED', 'REJECTED', 'WITHDRAWN'],
|
||||
IN_PROGRESS: ['PASSED', 'REJECTED', 'WITHDRAWN'],
|
||||
PASSED: ['COMPLETED', 'WITHDRAWN'],
|
||||
PENDING: ['IN_PROGRESS', 'REJECTED', 'WITHDRAWN'],
|
||||
IN_PROGRESS: ['COMPLETED', 'REJECTED', 'WITHDRAWN'],
|
||||
COMPLETED: ['PASSED', 'REJECTED'],
|
||||
PASSED: ['IN_PROGRESS', 'WITHDRAWN'],
|
||||
REJECTED: ['PENDING'], // re-include
|
||||
COMPLETED: [], // terminal
|
||||
WITHDRAWN: ['PENDING'], // re-include
|
||||
}
|
||||
|
||||
@@ -174,13 +175,44 @@ export async function activateRound(
|
||||
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
||||
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
||||
if (result.transitionedCount > 0) {
|
||||
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
|
||||
console.log(`[RoundEngine] On activation: auto-completed ${result.transitionedCount} projects with complete documents`)
|
||||
}
|
||||
}
|
||||
} catch (retroError) {
|
||||
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
|
||||
}
|
||||
|
||||
// Mentoring pass-through: for MENTORING rounds with passThroughIfNoRequest,
|
||||
// auto-set all PENDING projects to PASSED (they pass through unless they request mentoring)
|
||||
if (round.roundType === 'MENTORING') {
|
||||
try {
|
||||
const mentoringConfig = safeValidateRoundConfig('MENTORING', round.configJson as Record<string, unknown>)
|
||||
if (mentoringConfig.success && mentoringConfig.data.passThroughIfNoRequest) {
|
||||
const pendingProjects = await prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'PENDING' },
|
||||
select: { id: true, projectId: true, metadataJson: true },
|
||||
})
|
||||
let passedCount = 0
|
||||
for (const prs of pendingProjects) {
|
||||
const meta = (prs.metadataJson as Record<string, unknown>) ?? {}
|
||||
// Only pass-through projects that haven't requested mentoring
|
||||
if (!meta.mentoringRequested) {
|
||||
await prisma.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: { state: 'PASSED' },
|
||||
})
|
||||
passedCount++
|
||||
}
|
||||
}
|
||||
if (passedCount > 0) {
|
||||
console.log(`[RoundEngine] Mentoring pass-through: set ${passedCount} projects to PASSED`)
|
||||
}
|
||||
}
|
||||
} catch (mentoringError) {
|
||||
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
round: { id: updated.id, status: updated.status },
|
||||
@@ -311,6 +343,26 @@ export async function closeRound(
|
||||
detailsJson: { name: round.name, roundType: round.roundType },
|
||||
})
|
||||
|
||||
// Grace period / immediate finalization processing
|
||||
try {
|
||||
const config = round.configJson ? (round.configJson as Record<string, unknown>) : {}
|
||||
const gracePeriodHours = (config.gracePeriodHours as number) ?? 0
|
||||
|
||||
if (gracePeriodHours > 0) {
|
||||
const gracePeriodEndsAt = new Date(Date.now() + gracePeriodHours * 60 * 60 * 1000)
|
||||
await prisma.round.update({
|
||||
where: { id: roundId },
|
||||
data: { gracePeriodEndsAt },
|
||||
})
|
||||
console.log(`[RoundEngine] Grace period set for round ${roundId}: ${gracePeriodHours}h (until ${gracePeriodEndsAt.toISOString()})`)
|
||||
} else {
|
||||
await processRoundClose(roundId, actorId, prisma)
|
||||
console.log(`[RoundEngine] Processed round close for ${roundId} (no grace period)`)
|
||||
}
|
||||
} catch (processError) {
|
||||
console.error('[RoundEngine] processRoundClose after close failed (non-fatal):', processError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
round: { id: updated.id, status: updated.status },
|
||||
@@ -559,10 +611,10 @@ export async function transitionProject(
|
||||
return { success: false, errors: [`Round ${roundId} not found`] }
|
||||
}
|
||||
|
||||
if (round.status !== 'ROUND_ACTIVE') {
|
||||
if (round.status !== 'ROUND_ACTIVE' && round.status !== 'ROUND_CLOSED') {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Round is ${round.status}, must be ROUND_ACTIVE to transition projects`],
|
||||
errors: [`Round is ${round.status}, must be ROUND_ACTIVE or ROUND_CLOSED to transition projects`],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,12 +910,17 @@ export async function checkRequirementsAndTransition(
|
||||
return { transitioned: false }
|
||||
}
|
||||
|
||||
// All requirements met — transition to PASSED
|
||||
const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma)
|
||||
// If PENDING, first transition to IN_PROGRESS so the state machine path is valid
|
||||
if (currentState.state === 'PENDING') {
|
||||
await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
|
||||
}
|
||||
|
||||
// All requirements met — transition to COMPLETED (finalization will set PASSED/REJECTED)
|
||||
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to PASSED in round ${roundId} (all ${requirements.length} requirements met)`)
|
||||
return { transitioned: true, newState: 'PASSED' }
|
||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length + submissionRequirements.length} requirements met)`)
|
||||
return { transitioned: true, newState: 'COMPLETED' }
|
||||
}
|
||||
|
||||
return { transitioned: false }
|
||||
@@ -894,14 +951,85 @@ export async function batchCheckRequirementsAndTransition(
|
||||
}
|
||||
|
||||
if (transitioned.length > 0) {
|
||||
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`)
|
||||
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to COMPLETED in round ${roundId}`)
|
||||
}
|
||||
|
||||
return { transitionedCount: transitioned.length, projectIds: transitioned }
|
||||
}
|
||||
|
||||
// ─── Auto-Transition Hooks ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trigger PENDING → IN_PROGRESS when a project has activity.
|
||||
* Non-fatal: if the project is not PENDING, this is a no-op.
|
||||
*/
|
||||
export async function triggerInProgressOnActivity(
|
||||
projectId: string,
|
||||
roundId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const prs = await prisma.projectRoundState.findUnique({
|
||||
where: { projectId_roundId: { projectId, roundId } },
|
||||
select: { state: true },
|
||||
})
|
||||
|
||||
if (!prs || prs.state !== 'PENDING') return
|
||||
|
||||
const result = await transitionProject(projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma)
|
||||
if (result.success) {
|
||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to IN_PROGRESS in round ${roundId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RoundEngine] triggerInProgressOnActivity failed (non-fatal):', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all jury assignments for a project in an evaluation round are completed.
|
||||
* If yes, transition from IN_PROGRESS → COMPLETED.
|
||||
*/
|
||||
export async function checkEvaluationCompletionAndTransition(
|
||||
projectId: string,
|
||||
roundId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<{ transitioned: boolean }> {
|
||||
try {
|
||||
const prs = await prisma.projectRoundState.findUnique({
|
||||
where: { projectId_roundId: { projectId, roundId } },
|
||||
select: { state: true },
|
||||
})
|
||||
|
||||
if (!prs || prs.state !== 'IN_PROGRESS') return { transitioned: false }
|
||||
|
||||
// Check all assignments for this project in this round
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { projectId, roundId },
|
||||
select: { isCompleted: true },
|
||||
})
|
||||
|
||||
if (assignments.length === 0) return { transitioned: false }
|
||||
|
||||
const allCompleted = assignments.every((a: { isCompleted: boolean }) => a.isCompleted)
|
||||
if (!allCompleted) return { transitioned: false }
|
||||
|
||||
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
||||
if (result.success) {
|
||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${assignments.length} evaluations done)`)
|
||||
return { transitioned: true }
|
||||
}
|
||||
|
||||
return { transitioned: false }
|
||||
} catch (error) {
|
||||
console.error('[RoundEngine] checkEvaluationCompletionAndTransition failed (non-fatal):', error)
|
||||
return { transitioned: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internals ──────────────────────────────────────────────────────────────
|
||||
|
||||
function isTerminalState(state: ProjectRoundStateValue): boolean {
|
||||
return ['PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].includes(state)
|
||||
export function isTerminalState(state: ProjectRoundStateValue): boolean {
|
||||
return ['PASSED', 'REJECTED', 'WITHDRAWN'].includes(state)
|
||||
}
|
||||
|
||||
830
src/server/services/round-finalization.ts
Normal file
830
src/server/services/round-finalization.ts
Normal file
@@ -0,0 +1,830 @@
|
||||
/**
|
||||
* Round Finalization Service
|
||||
*
|
||||
* Handles the post-close lifecycle of a round:
|
||||
* - processRoundClose: auto-sets project states after a round closes
|
||||
* - getFinalizationSummary: aggregates data for the finalization review UI
|
||||
* - confirmFinalization: single transaction to apply outcomes, advance projects, send emails
|
||||
*/
|
||||
|
||||
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
|
||||
import { transitionProject, isTerminalState } from './round-engine'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import {
|
||||
sendStyledNotificationEmail,
|
||||
getRejectionNotificationTemplate,
|
||||
} from '@/lib/email'
|
||||
import { createBulkNotifications } from '../services/in-app-notification'
|
||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FinalizationSummary = {
|
||||
roundId: string
|
||||
roundName: string
|
||||
roundType: RoundType
|
||||
isGracePeriodActive: boolean
|
||||
gracePeriodEndsAt: Date | null
|
||||
isFinalized: boolean
|
||||
finalizedAt: Date | null
|
||||
stats: {
|
||||
pending: number
|
||||
inProgress: number
|
||||
completed: number
|
||||
passed: number
|
||||
rejected: number
|
||||
withdrawn: number
|
||||
}
|
||||
projects: Array<{
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
category: string | null
|
||||
country: string | null
|
||||
currentState: ProjectRoundStateValue
|
||||
proposedOutcome: ProjectRoundStateValue | null
|
||||
evaluationScore?: number | null
|
||||
rankPosition?: number | null
|
||||
}>
|
||||
categoryTargets: {
|
||||
startupTarget: number | null
|
||||
conceptTarget: number | null
|
||||
startupProposed: number
|
||||
conceptProposed: number
|
||||
}
|
||||
nextRound: { id: string; name: string } | null
|
||||
accountStats: {
|
||||
needsInvite: number
|
||||
hasAccount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ConfirmFinalizationResult = {
|
||||
advanced: number
|
||||
rejected: number
|
||||
emailsSent: number
|
||||
emailsFailed: number
|
||||
}
|
||||
|
||||
// ─── processRoundClose ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process project states after a round closes.
|
||||
* Auto-transitions projects to COMPLETED/REJECTED and sets proposedOutcome defaults.
|
||||
* Called immediately on close (if no grace period) or after grace period expires.
|
||||
*/
|
||||
export async function processRoundClose(
|
||||
roundId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<{ processed: number }> {
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
include: {
|
||||
competition: {
|
||||
select: {
|
||||
rounds: {
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) throw new Error(`Round ${roundId} not found`)
|
||||
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: { roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
competitionCategory: true,
|
||||
files: { where: { roundId }, select: { id: true, requirementId: true, submissionFileRequirementId: true } },
|
||||
assignments: { where: { roundId }, select: { isCompleted: true } },
|
||||
filteringResults: { where: { roundId }, select: { outcome: true, finalOutcome: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let processed = 0
|
||||
|
||||
// Pre-compute pass set for EVALUATION rounds using ranking scores + config
|
||||
let evaluationPassSet: Set<string> | null = null
|
||||
if ((round.roundType as RoundType) === 'EVALUATION') {
|
||||
evaluationPassSet = new Set<string>()
|
||||
const snapshot = await prisma.rankingSnapshot.findFirst({
|
||||
where: { roundId },
|
||||
orderBy: { createdAt: 'desc' as const },
|
||||
select: { startupRankingJson: true, conceptRankingJson: true },
|
||||
})
|
||||
if (snapshot) {
|
||||
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||
const advanceMode = (config.advanceMode as string) || 'count'
|
||||
const advanceScoreThreshold = (config.advanceScoreThreshold as number) ?? 6
|
||||
const startupAdvanceCount = (config.startupAdvanceCount as number) ?? 0
|
||||
const conceptAdvanceCount = (config.conceptAdvanceCount as number) ?? 0
|
||||
|
||||
type RankEntry = { projectId: string; avgGlobalScore: number | null; rank: number }
|
||||
const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[]
|
||||
const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[]
|
||||
|
||||
if (advanceMode === 'threshold') {
|
||||
for (const r of [...startupRanked, ...conceptRanked]) {
|
||||
if (r.avgGlobalScore != null && r.avgGlobalScore >= advanceScoreThreshold) {
|
||||
evaluationPassSet.add(r.projectId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 'count' mode — top N per category by rank
|
||||
const sortedStartup = [...startupRanked].sort((a, b) => a.rank - b.rank)
|
||||
const sortedConcept = [...conceptRanked].sort((a, b) => a.rank - b.rank)
|
||||
for (let i = 0; i < Math.min(startupAdvanceCount, sortedStartup.length); i++) {
|
||||
evaluationPassSet.add(sortedStartup[i].projectId)
|
||||
}
|
||||
for (let i = 0; i < Math.min(conceptAdvanceCount, sortedConcept.length); i++) {
|
||||
evaluationPassSet.add(sortedConcept[i].projectId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const prs of projectStates) {
|
||||
// Skip already-terminal states
|
||||
if (isTerminalState(prs.state)) {
|
||||
// Set proposed outcome to match current state for display
|
||||
if (!prs.proposedOutcome) {
|
||||
await prisma.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: { proposedOutcome: prs.state },
|
||||
})
|
||||
}
|
||||
processed++
|
||||
continue
|
||||
}
|
||||
|
||||
let targetState: ProjectRoundStateValue = prs.state
|
||||
let proposedOutcome: ProjectRoundStateValue = 'PASSED'
|
||||
|
||||
switch (round.roundType as RoundType) {
|
||||
case 'INTAKE':
|
||||
case 'SUBMISSION': {
|
||||
// Projects with activity → COMPLETED, purely PENDING → REJECTED
|
||||
if (prs.state === 'PENDING') {
|
||||
targetState = 'REJECTED' as ProjectRoundStateValue
|
||||
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
||||
} else if (prs.state === 'IN_PROGRESS' || prs.state === 'COMPLETED') {
|
||||
if (prs.state === 'IN_PROGRESS') targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'EVALUATION': {
|
||||
// Use ranking scores to determine pass/reject
|
||||
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
|
||||
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
|
||||
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
|
||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||
proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||
} else if (prs.state === 'PENDING') {
|
||||
targetState = 'REJECTED' as ProjectRoundStateValue
|
||||
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
||||
} else if (prs.state === 'COMPLETED') {
|
||||
proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'FILTERING': {
|
||||
// Use FilteringResult to determine outcome for each project
|
||||
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
|
||||
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
|
||||
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
|
||||
|
||||
if (prs.state === 'COMPLETED') {
|
||||
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||
} else if (prs.state === 'IN_PROGRESS') {
|
||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||
} else if (prs.state === 'PENDING') {
|
||||
// PENDING projects in filtering: check FilteringResult
|
||||
if (fr) {
|
||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||
} else {
|
||||
// No filtering result at all → reject
|
||||
targetState = 'REJECTED' as ProjectRoundStateValue
|
||||
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'MENTORING': {
|
||||
// Projects already PASSED (pass-through) stay PASSED
|
||||
if (prs.state === 'PASSED') {
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
} else if (prs.state === 'IN_PROGRESS') {
|
||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
} else if (prs.state === 'COMPLETED') {
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
} else if (prs.state === 'PENDING') {
|
||||
// Pending = never requested mentoring, pass through
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'LIVE_FINAL': {
|
||||
// All presented projects → COMPLETED
|
||||
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
} else if (prs.state === 'COMPLETED') {
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'DELIBERATION': {
|
||||
// All voted projects → COMPLETED
|
||||
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
} else if (prs.state === 'COMPLETED') {
|
||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Transition project if needed (admin override for non-standard paths)
|
||||
if (targetState !== prs.state && !isTerminalState(prs.state)) {
|
||||
// Need to handle multi-step transitions
|
||||
if (prs.state === 'PENDING' && targetState === 'COMPLETED') {
|
||||
await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
|
||||
await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
|
||||
} else if (prs.state === 'PENDING' && targetState === 'REJECTED') {
|
||||
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
|
||||
} else {
|
||||
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Set proposed outcome
|
||||
await prisma.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: { proposedOutcome },
|
||||
})
|
||||
|
||||
processed++
|
||||
}
|
||||
|
||||
return { processed }
|
||||
}
|
||||
|
||||
// ─── getFinalizationSummary ─────────────────────────────────────────────────
|
||||
|
||||
export async function getFinalizationSummary(
|
||||
roundId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<FinalizationSummary> {
|
||||
const round = await prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
include: {
|
||||
competition: {
|
||||
select: {
|
||||
rounds: {
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const isGracePeriodActive = !!(round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now && !round.finalizedAt)
|
||||
const isFinalized = !!round.finalizedAt
|
||||
|
||||
// Get config for category targets
|
||||
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||
|
||||
// Find next round
|
||||
const rounds = round.competition.rounds
|
||||
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
|
||||
const nextRound = currentIdx >= 0 && currentIdx < rounds.length - 1
|
||||
? rounds[currentIdx + 1]
|
||||
: null
|
||||
|
||||
// Get all project states with project details
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: { roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
})
|
||||
|
||||
// Compute stats
|
||||
const stats = { pending: 0, inProgress: 0, completed: 0, passed: 0, rejected: 0, withdrawn: 0 }
|
||||
for (const prs of projectStates) {
|
||||
switch (prs.state) {
|
||||
case 'PENDING': stats.pending++; break
|
||||
case 'IN_PROGRESS': stats.inProgress++; break
|
||||
case 'COMPLETED': stats.completed++; break
|
||||
case 'PASSED': stats.passed++; break
|
||||
case 'REJECTED': stats.rejected++; break
|
||||
case 'WITHDRAWN': stats.withdrawn++; break
|
||||
}
|
||||
}
|
||||
|
||||
// Get evaluation scores if this is an evaluation round
|
||||
let scoreMap = new Map<string, number>()
|
||||
let rankMap = new Map<string, number>()
|
||||
|
||||
if (round.roundType === 'EVALUATION') {
|
||||
// Get latest ranking snapshot (per-category fields)
|
||||
const snapshot = await prisma.rankingSnapshot.findFirst({
|
||||
where: { roundId },
|
||||
orderBy: { createdAt: 'desc' as const },
|
||||
select: { startupRankingJson: true, conceptRankingJson: true },
|
||||
})
|
||||
if (snapshot) {
|
||||
type RankEntry = { projectId: string; avgGlobalScore?: number; compositeScore?: number; rank?: number }
|
||||
const allRanked = [
|
||||
...((snapshot.startupRankingJson ?? []) as RankEntry[]),
|
||||
...((snapshot.conceptRankingJson ?? []) as RankEntry[]),
|
||||
]
|
||||
for (const r of allRanked) {
|
||||
if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore)
|
||||
else if (r.compositeScore != null) scoreMap.set(r.projectId, r.compositeScore)
|
||||
if (r.rank != null) rankMap.set(r.projectId, r.rank)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build project list
|
||||
const projects = projectStates.map((prs: any) => ({
|
||||
id: prs.project.id,
|
||||
title: prs.project.title,
|
||||
teamName: prs.project.teamName,
|
||||
category: prs.project.competitionCategory,
|
||||
country: prs.project.country,
|
||||
currentState: prs.state as ProjectRoundStateValue,
|
||||
proposedOutcome: prs.proposedOutcome as ProjectRoundStateValue | null,
|
||||
evaluationScore: scoreMap.get(prs.project.id) ?? null,
|
||||
rankPosition: rankMap.get(prs.project.id) ?? null,
|
||||
}))
|
||||
|
||||
// Category target progress
|
||||
const startupTarget = (config.startupAdvanceCount as number | undefined) ?? null
|
||||
const conceptTarget = (config.conceptAdvanceCount as number | undefined) ?? null
|
||||
|
||||
let startupProposed = 0
|
||||
let conceptProposed = 0
|
||||
for (const p of projects) {
|
||||
if (p.proposedOutcome === 'PASSED') {
|
||||
if (p.category === 'STARTUP') startupProposed++
|
||||
else if (p.category === 'BUSINESS_CONCEPT') conceptProposed++
|
||||
}
|
||||
}
|
||||
|
||||
// Account stats: count how many advancing projects need invite vs already have accounts
|
||||
let needsInvite = 0
|
||||
let hasAccount = 0
|
||||
const passedProjectIds = projects.filter((p: { proposedOutcome: string | null }) => p.proposedOutcome === 'PASSED').map((p: { id: string }) => p.id)
|
||||
if (passedProjectIds.length > 0) {
|
||||
const passedProjects = await prisma.project.findMany({
|
||||
where: { id: { in: passedProjectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
submittedBy: { select: { passwordHash: true } },
|
||||
teamMembers: { select: { user: { select: { passwordHash: true } } } },
|
||||
},
|
||||
})
|
||||
for (const p of passedProjects) {
|
||||
// Check team members first, then submittedBy
|
||||
const users = p.teamMembers.length > 0
|
||||
? p.teamMembers.map((tm: any) => tm.user)
|
||||
: p.submittedBy ? [p.submittedBy] : []
|
||||
const anyHasPassword = users.some((u: any) => !!u.passwordHash)
|
||||
if (anyHasPassword) hasAccount++
|
||||
else needsInvite++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roundId,
|
||||
roundName: round.name,
|
||||
roundType: round.roundType,
|
||||
isGracePeriodActive,
|
||||
gracePeriodEndsAt: round.gracePeriodEndsAt,
|
||||
isFinalized,
|
||||
finalizedAt: round.finalizedAt,
|
||||
stats,
|
||||
projects,
|
||||
categoryTargets: {
|
||||
startupTarget,
|
||||
conceptTarget,
|
||||
startupProposed,
|
||||
conceptProposed,
|
||||
},
|
||||
nextRound: nextRound ? { id: nextRound.id, name: nextRound.name } : null,
|
||||
accountStats: { needsInvite, hasAccount },
|
||||
}
|
||||
}
|
||||
|
||||
// ─── confirmFinalization ────────────────────────────────────────────────────
|
||||
|
||||
export async function confirmFinalization(
|
||||
roundId: string,
|
||||
options: {
|
||||
targetRoundId?: string
|
||||
advancementMessage?: string
|
||||
rejectionMessage?: string
|
||||
},
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<ConfirmFinalizationResult> {
|
||||
// Validate: round is CLOSED, not already finalized, grace period expired
|
||||
const round = await prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
include: {
|
||||
competition: {
|
||||
select: {
|
||||
id: true,
|
||||
rounds: {
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (round.status !== 'ROUND_CLOSED') {
|
||||
throw new Error(`Round must be ROUND_CLOSED to finalize, got ${round.status}`)
|
||||
}
|
||||
|
||||
if (round.finalizedAt) {
|
||||
throw new Error('Round is already finalized')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
if (round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now) {
|
||||
throw new Error('Cannot finalize: grace period is still active')
|
||||
}
|
||||
|
||||
// Determine target round
|
||||
const rounds = round.competition.rounds
|
||||
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
|
||||
const targetRoundId = options.targetRoundId
|
||||
?? (currentIdx >= 0 && currentIdx < rounds.length - 1
|
||||
? rounds[currentIdx + 1].id
|
||||
: undefined)
|
||||
|
||||
const targetRoundName = targetRoundId
|
||||
? rounds.find((r: { id: string }) => r.id === targetRoundId)?.name ?? 'Next Round'
|
||||
: 'Next Round'
|
||||
|
||||
// Execute finalization in a transaction
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
const projectStates = await tx.projectRoundState.findMany({
|
||||
where: { roundId, proposedOutcome: { not: null } },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let advanced = 0
|
||||
let rejected = 0
|
||||
|
||||
for (const prs of projectStates) {
|
||||
const proposed = prs.proposedOutcome as ProjectRoundStateValue
|
||||
|
||||
// Skip if already in the proposed state
|
||||
if (prs.state === proposed) {
|
||||
if (proposed === 'PASSED') advanced++
|
||||
else if (proposed === 'REJECTED') rejected++
|
||||
continue
|
||||
}
|
||||
|
||||
// Transition to proposed outcome
|
||||
if (proposed === 'PASSED' || proposed === 'REJECTED') {
|
||||
// Ensure we're in COMPLETED before transitioning to PASSED/REJECTED
|
||||
if (prs.state !== 'COMPLETED' && prs.state !== 'PASSED' && prs.state !== 'REJECTED') {
|
||||
// Force through intermediate states
|
||||
if (prs.state === 'PENDING') {
|
||||
await tx.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: { state: 'IN_PROGRESS' },
|
||||
})
|
||||
}
|
||||
if (prs.state === 'PENDING' || prs.state === 'IN_PROGRESS') {
|
||||
await tx.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: { state: 'COMPLETED' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Now transition to final state
|
||||
await tx.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: {
|
||||
state: proposed,
|
||||
exitedAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
if (proposed === 'PASSED') {
|
||||
advanced++
|
||||
|
||||
// Create ProjectRoundState in target round (if exists)
|
||||
if (targetRoundId) {
|
||||
await tx.projectRoundState.upsert({
|
||||
where: {
|
||||
projectId_roundId: {
|
||||
projectId: prs.projectId,
|
||||
roundId: targetRoundId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId: prs.projectId,
|
||||
roundId: targetRoundId,
|
||||
state: 'PENDING',
|
||||
enteredAt: now,
|
||||
},
|
||||
update: {}, // skip if already exists
|
||||
})
|
||||
}
|
||||
|
||||
// Update Project.status to ASSIGNED
|
||||
await tx.project.update({
|
||||
where: { id: prs.projectId },
|
||||
data: { status: 'ASSIGNED' },
|
||||
})
|
||||
|
||||
// Create ProjectStatusHistory
|
||||
await tx.projectStatusHistory.create({
|
||||
data: {
|
||||
projectId: prs.projectId,
|
||||
status: 'ASSIGNED',
|
||||
changedBy: actorId,
|
||||
reason: `Advanced from round "${round.name}" via finalization`,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
rejected++
|
||||
}
|
||||
|
||||
// Audit log per project
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'finalization.project_outcome',
|
||||
entityType: 'ProjectRoundState',
|
||||
entityId: prs.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
projectId: prs.projectId,
|
||||
roundId,
|
||||
previousState: prs.state,
|
||||
outcome: proposed,
|
||||
targetRoundId: proposed === 'PASSED' ? targetRoundId : null,
|
||||
} as Prisma.InputJsonValue,
|
||||
snapshotJson: {
|
||||
timestamp: now.toISOString(),
|
||||
emittedBy: 'round-finalization',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mark round as finalized
|
||||
await tx.round.update({
|
||||
where: { id: roundId },
|
||||
data: {
|
||||
finalizedAt: now,
|
||||
finalizedBy: actorId,
|
||||
},
|
||||
})
|
||||
|
||||
// Finalization audit
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'round.finalized',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
roundName: round.name,
|
||||
advanced,
|
||||
rejected,
|
||||
targetRoundId,
|
||||
hasCustomAdvancementMessage: !!options.advancementMessage,
|
||||
hasCustomRejectionMessage: !!options.rejectionMessage,
|
||||
} as Prisma.InputJsonValue,
|
||||
snapshotJson: {
|
||||
timestamp: now.toISOString(),
|
||||
emittedBy: 'round-finalization',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { advanced, rejected }
|
||||
})
|
||||
|
||||
// Send emails outside transaction (non-fatal)
|
||||
let emailsSent = 0
|
||||
let emailsFailed = 0
|
||||
|
||||
try {
|
||||
// Get all projects that were finalized
|
||||
const finalizedStates = await prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: { in: ['PASSED', 'REJECTED'] } },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByEmail: true,
|
||||
submittedByUserId: true,
|
||||
submittedBy: { select: { id: true, email: true, name: true, passwordHash: true } },
|
||||
teamMembers: {
|
||||
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Pre-generate invite tokens for passwordless users on advancing projects
|
||||
const inviteTokenMap = new Map<string, string>() // userId → token
|
||||
const expiryMs = await getInviteExpiryMs(prisma)
|
||||
|
||||
for (const prs of finalizedStates) {
|
||||
if (prs.state !== 'PASSED') continue
|
||||
const users = prs.project.teamMembers.length > 0
|
||||
? prs.project.teamMembers.map((tm: any) => tm.user)
|
||||
: prs.project.submittedBy ? [prs.project.submittedBy] : []
|
||||
for (const user of users) {
|
||||
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
|
||||
const token = generateInviteToken()
|
||||
inviteTokenMap.set(user.id, token)
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const advancedUserIds = new Set<string>()
|
||||
const rejectedUserIds = new Set<string>()
|
||||
|
||||
for (const prs of finalizedStates) {
|
||||
type Recipient = { email: string; name: string | null; userId: string | null }
|
||||
const recipients: Recipient[] = []
|
||||
for (const tm of prs.project.teamMembers) {
|
||||
if (tm.user.email) {
|
||||
recipients.push({ email: tm.user.email, name: tm.user.name, userId: tm.user.id })
|
||||
if (prs.state === 'PASSED') advancedUserIds.add(tm.user.id)
|
||||
else rejectedUserIds.add(tm.user.id)
|
||||
}
|
||||
}
|
||||
if (recipients.length === 0 && prs.project.submittedBy?.email) {
|
||||
recipients.push({
|
||||
email: prs.project.submittedBy.email,
|
||||
name: prs.project.submittedBy.name,
|
||||
userId: prs.project.submittedBy.id,
|
||||
})
|
||||
if (prs.state === 'PASSED') advancedUserIds.add(prs.project.submittedBy.id)
|
||||
else rejectedUserIds.add(prs.project.submittedBy.id)
|
||||
} else if (recipients.length === 0 && prs.project.submittedByEmail) {
|
||||
recipients.push({ email: prs.project.submittedByEmail, name: null, userId: null })
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
if (prs.state === 'PASSED') {
|
||||
// Build account creation URL for passwordless users
|
||||
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||
|
||||
await sendStyledNotificationEmail(
|
||||
recipient.email,
|
||||
recipient.name || '',
|
||||
'ADVANCEMENT_NOTIFICATION',
|
||||
{
|
||||
title: 'Your project has advanced!',
|
||||
message: '',
|
||||
linkUrl: accountUrl || '/applicant',
|
||||
metadata: {
|
||||
projectName: prs.project.title,
|
||||
fromRoundName: round.name,
|
||||
toRoundName: targetRoundName,
|
||||
customMessage: options.advancementMessage || undefined,
|
||||
accountUrl,
|
||||
},
|
||||
},
|
||||
)
|
||||
} else {
|
||||
await sendStyledNotificationEmail(
|
||||
recipient.email,
|
||||
recipient.name || '',
|
||||
'REJECTION_NOTIFICATION',
|
||||
{
|
||||
title: `Update on your application: "${prs.project.title}"`,
|
||||
message: '',
|
||||
metadata: {
|
||||
projectName: prs.project.title,
|
||||
roundName: round.name,
|
||||
customMessage: options.rejectionMessage || undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
emailsSent++
|
||||
} catch (err) {
|
||||
console.error(`[Finalization] Email failed for ${recipient.email}:`, err)
|
||||
emailsFailed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create in-app notifications
|
||||
if (advancedUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds: [...advancedUserIds],
|
||||
type: 'project_advanced',
|
||||
title: 'Your project has advanced!',
|
||||
message: `Your project has advanced from "${round.name}" to "${targetRoundName}".`,
|
||||
linkUrl: '/applicant',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'Trophy',
|
||||
priority: 'high',
|
||||
})
|
||||
}
|
||||
|
||||
if (rejectedUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds: [...rejectedUserIds],
|
||||
type: 'project_rejected',
|
||||
title: 'Competition Update',
|
||||
message: `Your project did not advance past "${round.name}".`,
|
||||
linkUrl: '/applicant',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'Info',
|
||||
priority: 'normal',
|
||||
})
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error('[Finalization] Email batch failed (non-fatal):', emailError)
|
||||
}
|
||||
|
||||
// External audit log
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
action: 'ROUND_FINALIZED',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
detailsJson: {
|
||||
roundName: round.name,
|
||||
advanced: result.advanced,
|
||||
rejected: result.rejected,
|
||||
emailsSent,
|
||||
emailsFailed,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
advanced: result.advanced,
|
||||
rejected: result.rejected,
|
||||
emailsSent,
|
||||
emailsFailed,
|
||||
}
|
||||
}
|
||||
25
src/server/utils/invite.ts
Normal file
25
src/server/utils/invite.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import crypto from 'crypto'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
const DEFAULT_INVITE_EXPIRY_HOURS = 72 // 3 days
|
||||
|
||||
export function generateInviteToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export async function getInviteExpiryHours(prisma: PrismaClient): Promise<number> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'invite_link_expiry_hours' },
|
||||
select: { value: true },
|
||||
})
|
||||
const hours = setting?.value ? parseInt(setting.value, 10) : DEFAULT_INVITE_EXPIRY_HOURS
|
||||
return isNaN(hours) || hours < 1 ? DEFAULT_INVITE_EXPIRY_HOURS : hours
|
||||
} catch {
|
||||
return DEFAULT_INVITE_EXPIRY_HOURS
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInviteExpiryMs(prisma: PrismaClient): Promise<number> {
|
||||
return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000
|
||||
}
|
||||
Reference in New Issue
Block a user