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,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user