Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
2026-02-19 12:59:35 +01:00
|
|
|
import { Prisma, type PrismaClient } from '@prisma/client'
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
|
|
|
|
import { logAudit } from '@/server/utils/audit'
|
|
|
|
|
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:09:52 +01:00
|
|
|
import { generateShortlist } from '../services/ai-shortlist'
|
2026-02-19 12:59:35 +01:00
|
|
|
import { createBulkNotifications } from '../services/in-app-notification'
|
2026-03-03 19:14:41 +01:00
|
|
|
import {
|
|
|
|
|
getAdvancementNotificationTemplate,
|
|
|
|
|
getRejectionNotificationTemplate,
|
|
|
|
|
sendStyledNotificationEmail,
|
|
|
|
|
sendInvitationEmail,
|
|
|
|
|
getBaseUrl,
|
|
|
|
|
} from '@/lib/email'
|
|
|
|
|
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
import {
|
|
|
|
|
openWindow,
|
|
|
|
|
closeWindow,
|
|
|
|
|
lockWindow,
|
|
|
|
|
checkDeadlinePolicy,
|
|
|
|
|
validateSubmission,
|
|
|
|
|
getVisibleWindows,
|
|
|
|
|
} from '../services/submission-manager'
|
|
|
|
|
|
|
|
|
|
const roundTypeEnum = z.enum([
|
|
|
|
|
'INTAKE',
|
|
|
|
|
'FILTERING',
|
|
|
|
|
'EVALUATION',
|
|
|
|
|
'SUBMISSION',
|
|
|
|
|
'MENTORING',
|
|
|
|
|
'LIVE_FINAL',
|
|
|
|
|
'DELIBERATION',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
export const roundRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Create a new round within a competition
|
|
|
|
|
*/
|
|
|
|
|
create: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
competitionId: z.string(),
|
|
|
|
|
name: z.string().min(1).max(255),
|
|
|
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
|
|
|
|
roundType: roundTypeEnum,
|
|
|
|
|
sortOrder: z.number().int().nonnegative(),
|
|
|
|
|
configJson: z.record(z.unknown()).optional(),
|
|
|
|
|
windowOpenAt: z.date().nullable().optional(),
|
|
|
|
|
windowCloseAt: z.date().nullable().optional(),
|
|
|
|
|
juryGroupId: z.string().nullable().optional(),
|
|
|
|
|
submissionWindowId: z.string().nullable().optional(),
|
|
|
|
|
purposeKey: z.string().nullable().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify competition exists
|
|
|
|
|
await ctx.prisma.competition.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.competitionId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Validate configJson against the Zod schema for this roundType
|
|
|
|
|
const config = input.configJson
|
|
|
|
|
? validateRoundConfig(input.roundType, input.configJson)
|
|
|
|
|
: defaultRoundConfig(input.roundType)
|
|
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
const round = await ctx.prisma.round.create({
|
|
|
|
|
data: {
|
|
|
|
|
competitionId: input.competitionId,
|
|
|
|
|
name: input.name,
|
|
|
|
|
slug: input.slug,
|
|
|
|
|
roundType: input.roundType,
|
|
|
|
|
sortOrder: input.sortOrder,
|
|
|
|
|
configJson: config as unknown as Prisma.InputJsonValue,
|
|
|
|
|
windowOpenAt: input.windowOpenAt ?? undefined,
|
|
|
|
|
windowCloseAt: input.windowCloseAt ?? undefined,
|
|
|
|
|
juryGroupId: input.juryGroupId ?? undefined,
|
|
|
|
|
submissionWindowId: input.submissionWindowId ?? undefined,
|
|
|
|
|
purposeKey: input.purposeKey ?? undefined,
|
|
|
|
|
},
|
|
|
|
|
})
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: round.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
name: input.name,
|
|
|
|
|
roundType: input.roundType,
|
|
|
|
|
competitionId: input.competitionId,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return round
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get round by ID with all relations
|
|
|
|
|
*/
|
|
|
|
|
getById: protectedProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const round = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
include: {
|
|
|
|
|
juryGroup: {
|
2026-02-17 14:27:01 +01:00
|
|
|
include: {
|
|
|
|
|
members: {
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { id: true, name: true, email: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
},
|
|
|
|
|
submissionWindow: {
|
|
|
|
|
include: { fileRequirements: true },
|
|
|
|
|
},
|
|
|
|
|
advancementRules: { orderBy: { sortOrder: 'asc' } },
|
|
|
|
|
visibleSubmissionWindows: {
|
|
|
|
|
include: { submissionWindow: true },
|
|
|
|
|
},
|
|
|
|
|
_count: {
|
|
|
|
|
select: { projectRoundStates: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!round) {
|
|
|
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return round
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update round settings/config
|
|
|
|
|
*/
|
|
|
|
|
update: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
id: z.string(),
|
|
|
|
|
name: z.string().min(1).max(255).optional(),
|
|
|
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
|
|
|
|
status: z.enum(['ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED']).optional(),
|
|
|
|
|
configJson: z.record(z.unknown()).optional(),
|
|
|
|
|
windowOpenAt: z.date().nullable().optional(),
|
|
|
|
|
windowCloseAt: z.date().nullable().optional(),
|
|
|
|
|
juryGroupId: z.string().nullable().optional(),
|
|
|
|
|
submissionWindowId: z.string().nullable().optional(),
|
|
|
|
|
purposeKey: z.string().nullable().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const { id, configJson, ...data } = input
|
|
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } })
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// If configJson provided, validate it against the round type
|
|
|
|
|
let validatedConfig: Prisma.InputJsonValue | undefined
|
|
|
|
|
if (configJson) {
|
|
|
|
|
const parsed = validateRoundConfig(existing.roundType, configJson)
|
|
|
|
|
validatedConfig = parsed as unknown as Prisma.InputJsonValue
|
|
|
|
|
}
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
const round = await ctx.prisma.round.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data: {
|
|
|
|
|
...data,
|
|
|
|
|
...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
|
|
|
|
|
},
|
|
|
|
|
})
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
changes: input,
|
|
|
|
|
previous: {
|
|
|
|
|
name: existing.name,
|
|
|
|
|
status: existing.status,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
},
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return round
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reorder rounds within a competition
|
|
|
|
|
*/
|
|
|
|
|
updateOrder: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
competitionId: z.string(),
|
|
|
|
|
roundIds: z.array(z.string()),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.$transaction(
|
|
|
|
|
input.roundIds.map((roundId, index) =>
|
|
|
|
|
ctx.prisma.round.update({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
data: { sortOrder: index },
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a round
|
|
|
|
|
*/
|
|
|
|
|
delete: adminProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id } })
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.round.delete({ where: { id: input.id } })
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'DELETE',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: input.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
name: existing.name,
|
|
|
|
|
roundType: existing.roundType,
|
|
|
|
|
competitionId: existing.competitionId,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
})
|
|
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
return existing
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
}),
|
|
|
|
|
|
2026-02-16 09:20:02 +01:00
|
|
|
// =========================================================================
|
|
|
|
|
// Project Advancement (Manual Only)
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Advance PASSED projects from one round to the next.
|
|
|
|
|
* This is ALWAYS manual — no auto-advancement after AI filtering.
|
|
|
|
|
* Admin must explicitly trigger this after reviewing results.
|
|
|
|
|
*/
|
|
|
|
|
advanceProjects: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
targetRoundId: z.string().optional(),
|
|
|
|
|
projectIds: z.array(z.string()).optional(),
|
2026-02-16 19:09:23 +01:00
|
|
|
autoPassPending: z.boolean().optional(),
|
2026-02-16 09:20:02 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-02-16 19:09:23 +01:00
|
|
|
const { roundId, targetRoundId, projectIds, autoPassPending } = input
|
2026-02-16 09:20:02 +01:00
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
// Get current round with competition context + status
|
2026-02-16 09:20:02 +01:00
|
|
|
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: roundId },
|
2026-02-19 12:59:35 +01:00
|
|
|
select: { id: true, name: true, competitionId: true, sortOrder: true, status: true, configJson: true },
|
2026-02-16 09:20:02 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
// Validate: current round must be ROUND_ACTIVE or ROUND_CLOSED
|
|
|
|
|
if (currentRound.status !== 'ROUND_ACTIVE' && currentRound.status !== 'ROUND_CLOSED') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: `Cannot advance from round with status ${currentRound.status}. Round must be ROUND_ACTIVE or ROUND_CLOSED.`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 09:20:02 +01:00
|
|
|
// Determine target round
|
2026-02-19 12:59:35 +01:00
|
|
|
let targetRound: { id: string; name: string; competitionId: string; sortOrder: number; configJson: unknown }
|
2026-02-16 09:20:02 +01:00
|
|
|
if (targetRoundId) {
|
|
|
|
|
targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: targetRoundId },
|
2026-02-19 12:59:35 +01:00
|
|
|
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
|
2026-02-16 09:20:02 +01:00
|
|
|
})
|
2026-02-19 12:59:35 +01:00
|
|
|
|
|
|
|
|
// Validate: target must be in same competition
|
|
|
|
|
if (targetRound.competitionId !== currentRound.competitionId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Target round must belong to the same competition as the source round.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate: target must be after current round
|
|
|
|
|
if (targetRound.sortOrder <= currentRound.sortOrder) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Target round must come after the current round (higher sortOrder).',
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-16 09:20:02 +01:00
|
|
|
} else {
|
|
|
|
|
// Find next round in same competition by sortOrder
|
|
|
|
|
const nextRound = await ctx.prisma.round.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
competitionId: currentRound.competitionId,
|
|
|
|
|
sortOrder: { gt: currentRound.sortOrder },
|
|
|
|
|
},
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
2026-02-19 12:59:35 +01:00
|
|
|
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
|
2026-02-16 09:20:02 +01:00
|
|
|
})
|
|
|
|
|
if (!nextRound) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'No subsequent round exists in this competition. Create the next round first.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
targetRound = nextRound
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
// Validate projectIds exist in current round if provided
|
2026-02-16 09:20:02 +01:00
|
|
|
if (projectIds && projectIds.length > 0) {
|
2026-02-19 12:59:35 +01:00
|
|
|
const existingStates = await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { roundId, projectId: { in: projectIds } },
|
2026-02-16 09:20:02 +01:00
|
|
|
select: { projectId: true },
|
|
|
|
|
})
|
2026-02-19 12:59:35 +01:00
|
|
|
const existingIds = new Set(existingStates.map((s) => s.projectId))
|
|
|
|
|
const missing = projectIds.filter((id) => !existingIds.has(id))
|
|
|
|
|
if (missing.length > 0) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: `Projects not found in current round: ${missing.join(', ')}`,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-16 09:20:02 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
// Transaction: auto-pass + create entries in target round + mark current as COMPLETED
|
|
|
|
|
let autoPassedCount = 0
|
|
|
|
|
let idsToAdvance: string[]
|
2026-02-16 09:20:02 +01:00
|
|
|
|
|
|
|
|
await ctx.prisma.$transaction(async (tx) => {
|
2026-02-19 12:59:35 +01:00
|
|
|
// Auto-pass all PENDING projects first (for intake/bulk workflows) — inside tx
|
|
|
|
|
if (autoPassPending) {
|
|
|
|
|
const result = await tx.projectRoundState.updateMany({
|
|
|
|
|
where: { roundId, state: 'PENDING' },
|
|
|
|
|
data: { state: 'PASSED' },
|
|
|
|
|
})
|
|
|
|
|
autoPassedCount = result.count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine which projects to advance
|
|
|
|
|
if (projectIds && projectIds.length > 0) {
|
|
|
|
|
idsToAdvance = projectIds
|
|
|
|
|
} else {
|
|
|
|
|
// Default: all PASSED projects in current round
|
|
|
|
|
const passedStates = await tx.projectRoundState.findMany({
|
|
|
|
|
where: { roundId, state: 'PASSED' },
|
|
|
|
|
select: { projectId: true },
|
|
|
|
|
})
|
|
|
|
|
idsToAdvance = passedStates.map((s) => s.projectId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (idsToAdvance.length === 0) return
|
|
|
|
|
|
2026-02-16 09:20:02 +01:00
|
|
|
// Create ProjectRoundState in target round
|
|
|
|
|
await tx.projectRoundState.createMany({
|
|
|
|
|
data: idsToAdvance.map((projectId) => ({
|
|
|
|
|
projectId,
|
|
|
|
|
roundId: targetRound.id,
|
|
|
|
|
})),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mark current round states as COMPLETED
|
|
|
|
|
await tx.projectRoundState.updateMany({
|
|
|
|
|
where: {
|
|
|
|
|
roundId,
|
|
|
|
|
projectId: { in: idsToAdvance },
|
|
|
|
|
state: 'PASSED',
|
|
|
|
|
},
|
|
|
|
|
data: { state: 'COMPLETED' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Update project status to ASSIGNED
|
|
|
|
|
await tx.project.updateMany({
|
|
|
|
|
where: { id: { in: idsToAdvance } },
|
|
|
|
|
data: { status: 'ASSIGNED' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Status history
|
|
|
|
|
await tx.projectStatusHistory.createMany({
|
|
|
|
|
data: idsToAdvance.map((projectId) => ({
|
|
|
|
|
projectId,
|
|
|
|
|
status: 'ASSIGNED',
|
|
|
|
|
changedBy: ctx.user?.id,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
// If nothing to advance (set inside tx), return early
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
|
|
|
if (!idsToAdvance! || idsToAdvance!.length === 0) {
|
|
|
|
|
return { advancedCount: 0, autoPassedCount, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 09:20:02 +01:00
|
|
|
// Audit
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'ADVANCE_PROJECTS',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
fromRound: currentRound.name,
|
|
|
|
|
toRound: targetRound.name,
|
|
|
|
|
targetRoundId: targetRound.id,
|
2026-02-19 12:59:35 +01:00
|
|
|
projectCount: idsToAdvance!.length,
|
2026-02-16 19:09:23 +01:00
|
|
|
autoPassedCount,
|
2026-02-19 12:59:35 +01:00
|
|
|
projectIds: idsToAdvance!,
|
2026-02-16 09:20:02 +01:00
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
2026-02-19 12:59:35 +01:00
|
|
|
advancedCount: idsToAdvance!.length,
|
2026-02-16 19:09:23 +01:00
|
|
|
autoPassedCount,
|
2026-02-16 09:20:02 +01:00
|
|
|
targetRoundId: targetRound.id,
|
|
|
|
|
targetRoundName: targetRound.name,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:09:52 +01:00
|
|
|
// =========================================================================
|
|
|
|
|
// AI Shortlist Recommendations
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate AI-powered shortlist recommendations for a round.
|
|
|
|
|
* Runs independently for STARTUP and BUSINESS_CONCEPT categories.
|
|
|
|
|
* Uses per-round config for advancement targets and file parsing.
|
|
|
|
|
*/
|
|
|
|
|
generateAIRecommendations: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
rubric: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
competitionId: true,
|
|
|
|
|
configJson: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const config = (round.configJson as Record<string, unknown>) ?? {}
|
|
|
|
|
const startupTopN = (config.startupAdvanceCount as number) || 10
|
|
|
|
|
const conceptTopN = (config.conceptAdvanceCount as number) || 10
|
|
|
|
|
const aiParseFiles = !!config.aiParseFiles
|
|
|
|
|
|
|
|
|
|
const result = await generateShortlist(
|
|
|
|
|
{
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
competitionId: round.competitionId,
|
|
|
|
|
startupTopN,
|
|
|
|
|
conceptTopN,
|
|
|
|
|
rubric: input.rubric,
|
|
|
|
|
aiParseFiles,
|
|
|
|
|
},
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'AI_SHORTLIST',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: input.roundId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
startupTopN,
|
|
|
|
|
conceptTopN,
|
|
|
|
|
aiParseFiles,
|
|
|
|
|
success: result.success,
|
|
|
|
|
startupCount: result.recommendations.STARTUP.length,
|
|
|
|
|
conceptCount: result.recommendations.BUSINESS_CONCEPT.length,
|
|
|
|
|
tokensUsed: result.tokensUsed,
|
|
|
|
|
errors: result.errors,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// =========================================================================
|
|
|
|
|
// Submission Window Management
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a submission window for a round
|
|
|
|
|
*/
|
|
|
|
|
createSubmissionWindow: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
competitionId: z.string(),
|
|
|
|
|
name: z.string().min(1).max(255),
|
|
|
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
|
|
|
|
roundNumber: z.number().int().min(1),
|
|
|
|
|
windowOpenAt: z.date().optional(),
|
|
|
|
|
windowCloseAt: z.date().optional(),
|
|
|
|
|
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).default('HARD_DEADLINE'),
|
|
|
|
|
graceHours: z.number().int().min(0).optional(),
|
|
|
|
|
lockOnClose: z.boolean().default(true),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
const window = await ctx.prisma.submissionWindow.create({
|
|
|
|
|
data: {
|
|
|
|
|
competitionId: input.competitionId,
|
|
|
|
|
name: input.name,
|
|
|
|
|
slug: input.slug,
|
|
|
|
|
roundNumber: input.roundNumber,
|
|
|
|
|
windowOpenAt: input.windowOpenAt,
|
|
|
|
|
windowCloseAt: input.windowCloseAt,
|
|
|
|
|
deadlinePolicy: input.deadlinePolicy,
|
|
|
|
|
graceHours: input.graceHours,
|
|
|
|
|
lockOnClose: input.lockOnClose,
|
|
|
|
|
},
|
|
|
|
|
})
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'SubmissionWindow',
|
|
|
|
|
entityId: window.id,
|
|
|
|
|
detailsJson: { name: input.name, competitionId: input.competitionId },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return window
|
|
|
|
|
}),
|
|
|
|
|
|
Rounds overhaul: full CRUD submission windows, scheduling UI, analytics, design refresh
- Fix special award FK crash: replace 4x raw auditLog.create with logAudit() helper
- Add updateSubmissionWindow + deleteSubmissionWindow mutations to round router
- Add per-round analytics (_count, juryGroup) to competition.getById
- Remove redundant acceptedCategories from intake config
- Rewrite submission window manager with full CRUD, all fields, date pickers
- Add round scheduling card (open/close dates) to round detail page
- Add project count, assignment count, jury group to round list cards
- Visual redesign: pipeline view, brand colors, progress bars, enhanced cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:07:09 +01:00
|
|
|
/**
|
|
|
|
|
* Update an existing submission window
|
|
|
|
|
*/
|
|
|
|
|
updateSubmissionWindow: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
id: z.string(),
|
|
|
|
|
name: z.string().min(1).max(255).optional(),
|
|
|
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
|
|
|
|
roundNumber: z.number().int().min(1).optional(),
|
|
|
|
|
windowOpenAt: z.date().nullable().optional(),
|
|
|
|
|
windowCloseAt: z.date().nullable().optional(),
|
|
|
|
|
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).optional(),
|
|
|
|
|
graceHours: z.number().int().min(0).nullable().optional(),
|
|
|
|
|
lockOnClose: z.boolean().optional(),
|
|
|
|
|
sortOrder: z.number().int().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const { id, ...data } = input
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
const window = await ctx.prisma.submissionWindow.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data,
|
|
|
|
|
})
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'SubmissionWindow',
|
|
|
|
|
entityId: id,
|
|
|
|
|
detailsJson: data,
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
Rounds overhaul: full CRUD submission windows, scheduling UI, analytics, design refresh
- Fix special award FK crash: replace 4x raw auditLog.create with logAudit() helper
- Add updateSubmissionWindow + deleteSubmissionWindow mutations to round router
- Add per-round analytics (_count, juryGroup) to competition.getById
- Remove redundant acceptedCategories from intake config
- Rewrite submission window manager with full CRUD, all fields, date pickers
- Add round scheduling card (open/close dates) to round detail page
- Add project count, assignment count, jury group to round list cards
- Visual redesign: pipeline view, brand colors, progress bars, enhanced cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:07:09 +01:00
|
|
|
})
|
|
|
|
|
return window
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a submission window (only if no files uploaded)
|
|
|
|
|
*/
|
|
|
|
|
deleteSubmissionWindow: adminProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Check if window has uploaded files
|
|
|
|
|
const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
select: { id: true, name: true, _count: { select: { projectFiles: true } } },
|
|
|
|
|
})
|
|
|
|
|
if (window._count.projectFiles > 0) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`,
|
|
|
|
|
})
|
|
|
|
|
}
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
await ctx.prisma.submissionWindow.delete({ where: { id: input.id } })
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'DELETE',
|
|
|
|
|
entityType: 'SubmissionWindow',
|
|
|
|
|
entityId: input.id,
|
|
|
|
|
detailsJson: { name: window.name },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
Rounds overhaul: full CRUD submission windows, scheduling UI, analytics, design refresh
- Fix special award FK crash: replace 4x raw auditLog.create with logAudit() helper
- Add updateSubmissionWindow + deleteSubmissionWindow mutations to round router
- Add per-round analytics (_count, juryGroup) to competition.getById
- Remove redundant acceptedCategories from intake config
- Rewrite submission window manager with full CRUD, all fields, date pickers
- Add round scheduling card (open/close dates) to round detail page
- Add project count, assignment count, jury group to round list cards
- Visual redesign: pipeline view, brand colors, progress bars, enhanced cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:07:09 +01:00
|
|
|
})
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
/**
|
|
|
|
|
* Open a submission window
|
|
|
|
|
*/
|
|
|
|
|
openSubmissionWindow: adminProcedure
|
|
|
|
|
.input(z.object({ windowId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await openWindow(input.windowId, ctx.user.id, ctx.prisma)
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to open window',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Close a submission window
|
|
|
|
|
*/
|
|
|
|
|
closeSubmissionWindow: adminProcedure
|
|
|
|
|
.input(z.object({ windowId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await closeWindow(input.windowId, ctx.user.id, ctx.prisma)
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to close window',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Lock a submission window
|
|
|
|
|
*/
|
|
|
|
|
lockSubmissionWindow: adminProcedure
|
|
|
|
|
.input(z.object({ windowId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await lockWindow(input.windowId, ctx.user.id, ctx.prisma)
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to lock window',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check deadline status of a window
|
|
|
|
|
*/
|
|
|
|
|
checkDeadline: protectedProcedure
|
|
|
|
|
.input(z.object({ windowId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return checkDeadlinePolicy(input.windowId, ctx.prisma)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate files against window requirements
|
|
|
|
|
*/
|
|
|
|
|
validateSubmission: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
windowId: z.string(),
|
|
|
|
|
files: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
mimeType: z.string(),
|
|
|
|
|
size: z.number(),
|
|
|
|
|
requirementId: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
return validateSubmission(input.projectId, input.windowId, input.files, ctx.prisma)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get visible submission windows for a round
|
|
|
|
|
*/
|
|
|
|
|
getVisibleWindows: protectedProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return getVisibleWindows(input.roundId, ctx.prisma)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// File Requirements Management
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a file requirement for a submission window
|
|
|
|
|
*/
|
|
|
|
|
createFileRequirement: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
submissionWindowId: z.string(),
|
|
|
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
|
|
|
|
label: z.string().min(1).max(255),
|
|
|
|
|
description: z.string().max(2000).optional(),
|
|
|
|
|
mimeTypes: z.array(z.string()).default([]),
|
|
|
|
|
maxSizeMb: z.number().int().min(0).optional(),
|
|
|
|
|
required: z.boolean().default(false),
|
|
|
|
|
sortOrder: z.number().int().default(0),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.submissionFileRequirement.create({
|
|
|
|
|
data: input,
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update a file requirement
|
|
|
|
|
*/
|
|
|
|
|
updateFileRequirement: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
id: z.string(),
|
|
|
|
|
label: z.string().min(1).max(255).optional(),
|
|
|
|
|
description: z.string().max(2000).optional().nullable(),
|
|
|
|
|
mimeTypes: z.array(z.string()).optional(),
|
|
|
|
|
maxSizeMb: z.number().min(0).optional().nullable(),
|
|
|
|
|
required: z.boolean().optional(),
|
|
|
|
|
sortOrder: z.number().int().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const { id, ...data } = input
|
|
|
|
|
return ctx.prisma.submissionFileRequirement.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data,
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a file requirement
|
|
|
|
|
*/
|
|
|
|
|
deleteFileRequirement: adminProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.submissionFileRequirement.delete({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get submission windows for applicants in a competition
|
|
|
|
|
*/
|
|
|
|
|
getApplicantWindows: protectedProcedure
|
|
|
|
|
.input(z.object({ competitionId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.submissionWindow.findMany({
|
|
|
|
|
where: { competitionId: input.competitionId },
|
|
|
|
|
include: {
|
|
|
|
|
fileRequirements: { orderBy: { sortOrder: 'asc' } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
}),
|
2026-03-01 14:47:42 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the most recent SUBMISSION round config for a program.
|
|
|
|
|
* Used on the project edit page to show required document slots.
|
|
|
|
|
*/
|
|
|
|
|
getSubmissionRoundForProgram: adminProcedure
|
|
|
|
|
.input(z.object({ programId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const round = await ctx.prisma.round.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
roundType: 'SUBMISSION',
|
|
|
|
|
competition: { programId: input.programId },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
configJson: true,
|
|
|
|
|
},
|
|
|
|
|
orderBy: { sortOrder: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
return round ?? null
|
|
|
|
|
}),
|
2026-03-03 19:14:41 +01:00
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// 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 }
|
|
|
|
|
}),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
})
|