Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
notifyProjectTeam,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { sendInvitationEmail } from '@/lib/email'
|
||||
@@ -34,7 +33,7 @@ export const projectRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
'SUBMITTED',
|
||||
@@ -55,8 +54,8 @@ export const projectRouter = router({
|
||||
'REJECTED',
|
||||
])
|
||||
).optional(),
|
||||
notInRoundId: z.string().optional(), // Exclude projects already in this round
|
||||
unassignedOnly: z.boolean().optional(), // Projects not in any round
|
||||
excludeInStageId: z.string().optional(), // Exclude projects already in this stage
|
||||
unassignedOnly: z.boolean().optional(), // Projects not in any stage
|
||||
search: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
@@ -76,7 +75,7 @@ export const projectRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const {
|
||||
programId, roundId, notInRoundId, status, statuses, unassignedOnly, search, tags,
|
||||
programId, stageId, excludeInStageId, status, statuses, unassignedOnly, search, tags,
|
||||
competitionCategory, oceanIssue, country,
|
||||
wantsMentorship, hasFiles, hasAssignments,
|
||||
page, perPage,
|
||||
@@ -89,25 +88,19 @@ export const projectRouter = router({
|
||||
// Filter by program
|
||||
if (programId) where.programId = programId
|
||||
|
||||
// Filter by round
|
||||
if (roundId) {
|
||||
where.roundId = roundId
|
||||
// Filter by stage (via ProjectStageState join)
|
||||
if (stageId) {
|
||||
where.stageStates = { some: { stageId } }
|
||||
}
|
||||
|
||||
// Exclude projects in a specific round (include unassigned projects with roundId=null)
|
||||
if (notInRoundId) {
|
||||
if (!where.AND) where.AND = []
|
||||
;(where.AND as unknown[]).push({
|
||||
OR: [
|
||||
{ roundId: null },
|
||||
{ roundId: { not: notInRoundId } },
|
||||
],
|
||||
})
|
||||
// Exclude projects already in a specific stage
|
||||
if (excludeInStageId) {
|
||||
where.stageStates = { none: { stageId: excludeInStageId } }
|
||||
}
|
||||
|
||||
// Filter by unassigned (no round)
|
||||
// Filter by unassigned (not in any stage)
|
||||
if (unassignedOnly) {
|
||||
where.roundId = null
|
||||
where.stageStates = { none: {} }
|
||||
}
|
||||
|
||||
// Status filter
|
||||
@@ -153,13 +146,7 @@ export const projectRouter = router({
|
||||
take: perPage,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
round: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
_count: { select: { assignments: true, files: true } },
|
||||
},
|
||||
}),
|
||||
@@ -183,8 +170,8 @@ export const projectRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
notInRoundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
excludeInStageId: z.string().optional(),
|
||||
unassignedOnly: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
statuses: z.array(
|
||||
@@ -213,7 +200,7 @@ export const projectRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const {
|
||||
programId, roundId, notInRoundId, unassignedOnly,
|
||||
programId, stageId, excludeInStageId, unassignedOnly,
|
||||
search, statuses, tags,
|
||||
competitionCategory, oceanIssue, country,
|
||||
wantsMentorship, hasFiles, hasAssignments,
|
||||
@@ -222,17 +209,15 @@ export const projectRouter = router({
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (programId) where.programId = programId
|
||||
if (roundId) where.roundId = roundId
|
||||
if (notInRoundId) {
|
||||
if (!where.AND) where.AND = []
|
||||
;(where.AND as unknown[]).push({
|
||||
OR: [
|
||||
{ roundId: null },
|
||||
{ roundId: { not: notInRoundId } },
|
||||
],
|
||||
})
|
||||
if (stageId) {
|
||||
where.stageStates = { some: { stageId } }
|
||||
}
|
||||
if (excludeInStageId) {
|
||||
where.stageStates = { none: { stageId: excludeInStageId } }
|
||||
}
|
||||
if (unassignedOnly) {
|
||||
where.stageStates = { none: {} }
|
||||
}
|
||||
if (unassignedOnly) where.roundId = null
|
||||
if (statuses?.length) where.status = { in: statuses }
|
||||
if (tags && tags.length > 0) where.tags = { hasSome: tags }
|
||||
if (competitionCategory) where.competitionCategory = competitionCategory
|
||||
@@ -265,11 +250,7 @@ export const projectRouter = router({
|
||||
*/
|
||||
getFilterOptions: protectedProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const [rounds, countries, categories, issues] = await Promise.all([
|
||||
ctx.prisma.round.findMany({
|
||||
select: { id: true, name: true, program: { select: { id: true, name: true, year: true } } },
|
||||
orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
|
||||
}),
|
||||
const [countries, categories, issues] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where: { country: { not: null } },
|
||||
select: { country: true },
|
||||
@@ -289,7 +270,6 @@ export const projectRouter = router({
|
||||
])
|
||||
|
||||
return {
|
||||
rounds,
|
||||
countries: countries.map((c) => c.country).filter(Boolean) as string[],
|
||||
categories: categories.map((c) => ({
|
||||
value: c.competitionCategory!,
|
||||
@@ -312,7 +292,6 @@ export const projectRouter = router({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
files: true,
|
||||
round: true,
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
@@ -394,13 +373,12 @@ export const projectRouter = router({
|
||||
|
||||
/**
|
||||
* Create a single project (admin only)
|
||||
* Projects belong to a round.
|
||||
* Projects belong to a program.
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
title: z.string().min(1).max(500),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -437,15 +415,7 @@ export const projectRouter = router({
|
||||
...rest
|
||||
} = input
|
||||
|
||||
// If roundId provided, derive programId from round for validation
|
||||
let resolvedProgramId = input.programId
|
||||
if (input.roundId) {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { programId: true },
|
||||
})
|
||||
resolvedProgramId = round.programId
|
||||
}
|
||||
const resolvedProgramId = input.programId
|
||||
|
||||
// Build metadata from contact fields + any additional metadata
|
||||
const fullMetadata: Record<string, unknown> = { ...metadataJson }
|
||||
@@ -460,19 +430,9 @@ export const projectRouter = router({
|
||||
: undefined
|
||||
|
||||
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Auto-assign to first round if no roundId provided
|
||||
let resolvedRoundId = input.roundId || null
|
||||
if (!resolvedRoundId) {
|
||||
const firstRound = await getFirstRoundForProgram(tx, resolvedProgramId)
|
||||
if (firstRound) {
|
||||
resolvedRoundId = firstRound.id
|
||||
}
|
||||
}
|
||||
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
programId: resolvedProgramId,
|
||||
roundId: resolvedRoundId,
|
||||
title: input.title,
|
||||
teamName: input.teamName,
|
||||
description: input.description,
|
||||
@@ -545,7 +505,6 @@ export const projectRouter = router({
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
title: input.title,
|
||||
roundId: input.roundId,
|
||||
programId: resolvedProgramId,
|
||||
teamMembersCount: teamMembersInput?.length || 0,
|
||||
},
|
||||
@@ -599,7 +558,6 @@ export const projectRouter = router({
|
||||
|
||||
/**
|
||||
* Update a project (admin only)
|
||||
* Status updates require a roundId context since status is per-round.
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
@@ -609,8 +567,6 @@ export const projectRouter = router({
|
||||
teamName: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized)
|
||||
// Status update requires roundId
|
||||
roundId: z.string().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
'SUBMITTED',
|
||||
@@ -626,7 +582,7 @@ export const projectRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, metadataJson, status, roundId, country, ...data } = input
|
||||
const { id, metadataJson, status, country, ...data } = input
|
||||
|
||||
// Normalize country to ISO-2 code if provided
|
||||
const normalizedCountry = country !== undefined
|
||||
@@ -675,90 +631,40 @@ export const projectRouter = router({
|
||||
|
||||
// Send notifications if status changed
|
||||
if (status) {
|
||||
// Get round details for notification
|
||||
const projectWithRound = await ctx.prisma.project.findUnique({
|
||||
where: { id },
|
||||
include: { round: { select: { name: true, entryNotificationType: true, program: { select: { name: true } } } } },
|
||||
})
|
||||
|
||||
const round = projectWithRound?.round
|
||||
|
||||
// Helper to get notification title based on type
|
||||
const getNotificationTitle = (type: string): string => {
|
||||
const titles: Record<string, string> = {
|
||||
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
|
||||
ADVANCED_FINAL: "Amazing News! You're a Finalist",
|
||||
NOT_SELECTED: 'Application Status Update',
|
||||
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
|
||||
}
|
||||
return titles[type] || 'Project Update'
|
||||
const notificationConfig: Record<
|
||||
string,
|
||||
{ type: string; title: string; message: string }
|
||||
> = {
|
||||
SEMIFINALIST: {
|
||||
type: NotificationTypes.ADVANCED_SEMIFINAL,
|
||||
title: "Congratulations! You're a Semi-Finalist",
|
||||
message: `Your project "${project.title}" has advanced to the semi-finals!`,
|
||||
},
|
||||
FINALIST: {
|
||||
type: NotificationTypes.ADVANCED_FINAL,
|
||||
title: "Amazing News! You're a Finalist",
|
||||
message: `Your project "${project.title}" has been selected as a finalist!`,
|
||||
},
|
||||
REJECTED: {
|
||||
type: NotificationTypes.NOT_SELECTED,
|
||||
title: 'Application Status Update',
|
||||
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
|
||||
},
|
||||
}
|
||||
|
||||
// Helper to get notification message based on type
|
||||
const getNotificationMessage = (type: string, projectName: string): string => {
|
||||
const messages: Record<string, (name: string) => string> = {
|
||||
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
|
||||
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
|
||||
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
|
||||
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
|
||||
}
|
||||
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
|
||||
}
|
||||
|
||||
// Use round's configured notification type, or fall back to status-based defaults
|
||||
if (round?.entryNotificationType) {
|
||||
const config = notificationConfig[status]
|
||||
if (config) {
|
||||
await notifyProjectTeam(id, {
|
||||
type: round.entryNotificationType,
|
||||
title: getNotificationTitle(round.entryNotificationType),
|
||||
message: getNotificationMessage(round.entryNotificationType, project.title),
|
||||
type: config.type,
|
||||
title: config.title,
|
||||
message: config.message,
|
||||
linkUrl: `/team/projects/${id}`,
|
||||
linkLabel: 'View Project',
|
||||
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
|
||||
priority: status === 'REJECTED' ? 'normal' : 'high',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
roundName: round.name,
|
||||
programName: round.program?.name,
|
||||
},
|
||||
})
|
||||
} else if (round) {
|
||||
// Fall back to hardcoded status-based notifications
|
||||
const notificationConfig: Record<
|
||||
string,
|
||||
{ type: string; title: string; message: string }
|
||||
> = {
|
||||
SEMIFINALIST: {
|
||||
type: NotificationTypes.ADVANCED_SEMIFINAL,
|
||||
title: "Congratulations! You're a Semi-Finalist",
|
||||
message: `Your project "${project.title}" has advanced to the semi-finals!`,
|
||||
},
|
||||
FINALIST: {
|
||||
type: NotificationTypes.ADVANCED_FINAL,
|
||||
title: "Amazing News! You're a Finalist",
|
||||
message: `Your project "${project.title}" has been selected as a finalist!`,
|
||||
},
|
||||
REJECTED: {
|
||||
type: NotificationTypes.NOT_SELECTED,
|
||||
title: 'Application Status Update',
|
||||
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
|
||||
},
|
||||
}
|
||||
|
||||
const config = notificationConfig[status]
|
||||
if (config) {
|
||||
await notifyProjectTeam(id, {
|
||||
type: config.type,
|
||||
title: config.title,
|
||||
message: config.message,
|
||||
linkUrl: `/team/projects/${id}`,
|
||||
linkLabel: 'View Project',
|
||||
priority: status === 'REJECTED' ? 'normal' : 'high',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
roundName: round?.name,
|
||||
programName: round?.program?.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,13 +761,12 @@ export const projectRouter = router({
|
||||
|
||||
/**
|
||||
* Import projects from CSV data (admin only)
|
||||
* Projects belong to a program. Optionally assign to a round.
|
||||
* Projects belong to a program.
|
||||
*/
|
||||
importCSV: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
projects: z.array(
|
||||
z.object({
|
||||
title: z.string().min(1),
|
||||
@@ -879,37 +784,13 @@ export const projectRouter = router({
|
||||
where: { id: input.programId },
|
||||
})
|
||||
|
||||
// Verify round exists and belongs to program if provided
|
||||
if (input.roundId) {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
if (round.programId !== input.programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Round does not belong to the selected program',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-assign to first round if no roundId provided
|
||||
let resolvedImportRoundId = input.roundId || null
|
||||
if (!resolvedImportRoundId) {
|
||||
const firstRound = await getFirstRoundForProgram(ctx.prisma, input.programId)
|
||||
if (firstRound) {
|
||||
resolvedImportRoundId = firstRound.id
|
||||
}
|
||||
}
|
||||
|
||||
// Create projects in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create all projects with roundId and programId
|
||||
const projectData = input.projects.map((p) => {
|
||||
const { metadataJson, ...rest } = p
|
||||
return {
|
||||
...rest,
|
||||
programId: input.programId,
|
||||
roundId: resolvedImportRoundId,
|
||||
status: 'SUBMITTED' as const,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
}
|
||||
@@ -929,7 +810,7 @@ export const projectRouter = router({
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
|
||||
detailsJson: { programId: input.programId, count: result.imported },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
@@ -942,13 +823,11 @@ export const projectRouter = router({
|
||||
*/
|
||||
getTags: protectedProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string().optional(),
|
||||
programId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
if (input.programId) where.round = { programId: input.programId }
|
||||
if (input.roundId) where.roundId = input.roundId
|
||||
if (input.programId) where.programId = input.programId
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: Object.keys(where).length > 0 ? where : undefined,
|
||||
@@ -963,13 +842,11 @@ export const projectRouter = router({
|
||||
|
||||
/**
|
||||
* Update project status in bulk (admin only)
|
||||
* Status is per-round, so roundId is required.
|
||||
*/
|
||||
bulkUpdateStatus: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
roundId: z.string(),
|
||||
status: z.enum([
|
||||
'SUBMITTED',
|
||||
'ELIGIBLE',
|
||||
@@ -982,25 +859,18 @@ export const projectRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Fetch matching projects BEFORE update so notifications match actually-updated records
|
||||
const [projects, round] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
roundId: input.roundId,
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
}),
|
||||
ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
|
||||
}),
|
||||
])
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
|
||||
const matchingIds = projects.map((p) => p.id)
|
||||
|
||||
// Validate status transitions for all projects
|
||||
const projectsWithStatus = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: matchingIds }, roundId: input.roundId },
|
||||
where: { id: { in: matchingIds } },
|
||||
select: { id: true, title: true, status: true },
|
||||
})
|
||||
const invalidTransitions: string[] = []
|
||||
@@ -1019,7 +889,7 @@ export const projectRouter = router({
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.project.updateMany({
|
||||
where: { id: { in: matchingIds }, roundId: input.roundId },
|
||||
where: { id: { in: matchingIds } },
|
||||
data: { status: input.status },
|
||||
})
|
||||
|
||||
@@ -1038,7 +908,7 @@ export const projectRouter = router({
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE_STATUS',
|
||||
entityType: 'Project',
|
||||
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: result.count },
|
||||
detailsJson: { ids: matchingIds, status: input.status, count: result.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
@@ -1046,89 +916,45 @@ export const projectRouter = router({
|
||||
return result
|
||||
})
|
||||
|
||||
// Helper to get notification title based on type
|
||||
const getNotificationTitle = (type: string): string => {
|
||||
const titles: Record<string, string> = {
|
||||
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
|
||||
ADVANCED_FINAL: "Amazing News! You're a Finalist",
|
||||
NOT_SELECTED: 'Application Status Update',
|
||||
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
|
||||
}
|
||||
return titles[type] || 'Project Update'
|
||||
}
|
||||
|
||||
// Helper to get notification message based on type
|
||||
const getNotificationMessage = (type: string, projectName: string): string => {
|
||||
const messages: Record<string, (name: string) => string> = {
|
||||
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
|
||||
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
|
||||
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
|
||||
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
|
||||
}
|
||||
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
|
||||
}
|
||||
|
||||
// Notify project teams based on round's configured notification or status-based fallback
|
||||
// Notify project teams based on status
|
||||
if (projects.length > 0) {
|
||||
if (round?.entryNotificationType) {
|
||||
// Use round's configured notification type
|
||||
const notificationConfig: Record<
|
||||
string,
|
||||
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
|
||||
> = {
|
||||
SEMIFINALIST: {
|
||||
type: NotificationTypes.ADVANCED_SEMIFINAL,
|
||||
titleFn: () => "Congratulations! You're a Semi-Finalist",
|
||||
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
|
||||
},
|
||||
FINALIST: {
|
||||
type: NotificationTypes.ADVANCED_FINAL,
|
||||
titleFn: () => "Amazing News! You're a Finalist",
|
||||
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
|
||||
},
|
||||
REJECTED: {
|
||||
type: NotificationTypes.NOT_SELECTED,
|
||||
titleFn: () => 'Application Status Update',
|
||||
messageFn: (name) =>
|
||||
`We regret to inform you that "${name}" was not selected for the next round.`,
|
||||
},
|
||||
}
|
||||
|
||||
const config = notificationConfig[input.status]
|
||||
if (config) {
|
||||
for (const project of projects) {
|
||||
await notifyProjectTeam(project.id, {
|
||||
type: round.entryNotificationType,
|
||||
title: getNotificationTitle(round.entryNotificationType),
|
||||
message: getNotificationMessage(round.entryNotificationType, project.title),
|
||||
type: config.type,
|
||||
title: config.titleFn(project.title),
|
||||
message: config.messageFn(project.title),
|
||||
linkUrl: `/team/projects/${project.id}`,
|
||||
linkLabel: 'View Project',
|
||||
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
|
||||
priority: input.status === 'REJECTED' ? 'normal' : 'high',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
roundName: round.name,
|
||||
programName: round.program?.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Fall back to hardcoded status-based notifications
|
||||
const notificationConfig: Record<
|
||||
string,
|
||||
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
|
||||
> = {
|
||||
SEMIFINALIST: {
|
||||
type: NotificationTypes.ADVANCED_SEMIFINAL,
|
||||
titleFn: () => "Congratulations! You're a Semi-Finalist",
|
||||
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
|
||||
},
|
||||
FINALIST: {
|
||||
type: NotificationTypes.ADVANCED_FINAL,
|
||||
titleFn: () => "Amazing News! You're a Finalist",
|
||||
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
|
||||
},
|
||||
REJECTED: {
|
||||
type: NotificationTypes.NOT_SELECTED,
|
||||
titleFn: () => 'Application Status Update',
|
||||
messageFn: (name) =>
|
||||
`We regret to inform you that "${name}" was not selected for the next round.`,
|
||||
},
|
||||
}
|
||||
|
||||
const config = notificationConfig[input.status]
|
||||
if (config) {
|
||||
for (const project of projects) {
|
||||
await notifyProjectTeam(project.id, {
|
||||
type: config.type,
|
||||
title: config.titleFn(project.title),
|
||||
message: config.messageFn(project.title),
|
||||
linkUrl: `/team/projects/${project.id}`,
|
||||
linkLabel: 'View Project',
|
||||
priority: input.status === 'REJECTED' ? 'normal' : 'high',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
roundName: round?.name,
|
||||
programName: round?.program?.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,7 +962,7 @@ export const projectRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* List projects in a program's pool (not assigned to any round)
|
||||
* List projects in a program's pool (not assigned to any stage)
|
||||
*/
|
||||
listPool: adminProcedure
|
||||
.input(
|
||||
@@ -1153,7 +979,7 @@ export const projectRouter = router({
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
roundId: null,
|
||||
stageStates: { none: {} }, // Projects not assigned to any stage
|
||||
}
|
||||
|
||||
if (search) {
|
||||
@@ -1196,7 +1022,6 @@ export const projectRouter = router({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
files: true,
|
||||
round: true,
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
|
||||
Reference in New Issue
Block a user