Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
When scoringMode is not 'binary', binaryDecision is null even though jurors answer boolean criteria (e.g. "Do you recommend?"). Now falls back to checking boolean values in criterionScoresJson. Hides the recommendation line entirely when no boolean data exists. Fixed in both analytics.ts (observer) and project.ts (admin). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1364 lines
44 KiB
TypeScript
1364 lines
44 KiB
TypeScript
import crypto from 'crypto'
|
|
import { z } from 'zod'
|
|
import { TRPCError } from '@trpc/server'
|
|
import { Prisma } from '@prisma/client'
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
|
import {
|
|
notifyProjectTeam,
|
|
NotificationTypes,
|
|
} from '../services/in-app-notification'
|
|
import { normalizeCountryToCode } from '@/lib/countries'
|
|
import { logAudit } from '../utils/audit'
|
|
import { sendInvitationEmail } from '@/lib/email'
|
|
|
|
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const
|
|
|
|
// Valid project status transitions
|
|
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
|
SUBMITTED: ['ELIGIBLE', 'REJECTED'], // New submissions get screened
|
|
ELIGIBLE: ['ASSIGNED', 'REJECTED'], // Eligible projects get assigned to jurors
|
|
ASSIGNED: ['SEMIFINALIST', 'FINALIST', 'REJECTED'], // After evaluation
|
|
SEMIFINALIST: ['FINALIST', 'REJECTED'], // Semi-finalists advance or get cut
|
|
FINALIST: ['REJECTED'], // Finalists can only be rejected (rare)
|
|
REJECTED: ['SUBMITTED'], // Rejected can be re-submitted (admin override)
|
|
}
|
|
|
|
export const projectRouter = router({
|
|
/**
|
|
* List projects with filtering and pagination
|
|
* Admin sees all, jury sees only assigned projects
|
|
*/
|
|
list: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
programId: z.string().optional(),
|
|
roundId: z.string().optional(),
|
|
status: z
|
|
.enum([
|
|
'SUBMITTED',
|
|
'ELIGIBLE',
|
|
'ASSIGNED',
|
|
'SEMIFINALIST',
|
|
'FINALIST',
|
|
'REJECTED',
|
|
])
|
|
.optional(),
|
|
statuses: z.array(
|
|
z.enum([
|
|
'SUBMITTED',
|
|
'ELIGIBLE',
|
|
'ASSIGNED',
|
|
'SEMIFINALIST',
|
|
'FINALIST',
|
|
'REJECTED',
|
|
])
|
|
).optional(),
|
|
excludeInRoundId: z.string().optional(), // Exclude projects already in this round
|
|
unassignedOnly: z.boolean().optional(), // Projects not in any round
|
|
search: z.string().optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
|
oceanIssue: z.enum([
|
|
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
|
|
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
|
|
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
|
|
'OCEAN_ACIDIFICATION', 'OTHER',
|
|
]).optional(),
|
|
country: z.string().optional(),
|
|
wantsMentorship: z.boolean().optional(),
|
|
hasFiles: z.boolean().optional(),
|
|
hasAssignments: z.boolean().optional(),
|
|
page: z.number().int().min(1).default(1),
|
|
perPage: z.number().int().min(1).max(200).default(20),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const {
|
|
programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
|
|
competitionCategory, oceanIssue, country,
|
|
wantsMentorship, hasFiles, hasAssignments,
|
|
page, perPage,
|
|
} = input
|
|
const skip = (page - 1) * perPage
|
|
|
|
// Build where clause
|
|
const where: Record<string, unknown> = {}
|
|
|
|
// Filter by program
|
|
if (programId) where.programId = programId
|
|
|
|
// Filter by round (via ProjectRoundState)
|
|
if (roundId) {
|
|
where.projectRoundStates = { some: { roundId } }
|
|
}
|
|
|
|
// Exclude projects already in a specific round
|
|
if (excludeInRoundId) {
|
|
where.projectRoundStates = { none: { roundId: excludeInRoundId } }
|
|
}
|
|
|
|
// Filter by unassigned (not in any round)
|
|
if (unassignedOnly) {
|
|
where.projectRoundStates = { none: {} }
|
|
}
|
|
|
|
// Status filter
|
|
if (statuses?.length || status) {
|
|
const statusValues = statuses?.length ? statuses : status ? [status] : []
|
|
if (statusValues.length > 0) {
|
|
where.status = { in: statusValues }
|
|
}
|
|
}
|
|
|
|
if (tags && tags.length > 0) {
|
|
where.tags = { hasSome: tags }
|
|
}
|
|
if (competitionCategory) where.competitionCategory = competitionCategory
|
|
if (oceanIssue) where.oceanIssue = oceanIssue
|
|
if (country) where.country = country
|
|
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
|
|
if (hasFiles === true) where.files = { some: {} }
|
|
if (hasFiles === false) where.files = { none: {} }
|
|
if (hasAssignments === true) where.assignments = { some: {} }
|
|
if (hasAssignments === false) where.assignments = { none: {} }
|
|
|
|
if (search) {
|
|
where.OR = [
|
|
{ title: { contains: search, mode: 'insensitive' } },
|
|
{ teamName: { contains: search, mode: 'insensitive' } },
|
|
{ description: { contains: search, mode: 'insensitive' } },
|
|
]
|
|
}
|
|
|
|
// Jury members can only see assigned projects
|
|
if (ctx.user.role === 'JURY_MEMBER') {
|
|
where.assignments = {
|
|
...((where.assignments as Record<string, unknown>) || {}),
|
|
some: { userId: ctx.user.id },
|
|
}
|
|
}
|
|
|
|
const [projects, total, statusGroups] = await Promise.all([
|
|
ctx.prisma.project.findMany({
|
|
where,
|
|
skip,
|
|
take: perPage,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
program: { select: { id: true, name: true, year: true } },
|
|
_count: { select: { assignments: true, files: true } },
|
|
},
|
|
}),
|
|
ctx.prisma.project.count({ where }),
|
|
ctx.prisma.project.groupBy({
|
|
by: ['status'],
|
|
where,
|
|
_count: true,
|
|
}),
|
|
])
|
|
|
|
// Build status counts from groupBy (across all pages)
|
|
const statusCounts: Record<string, number> = {}
|
|
for (const g of statusGroups) {
|
|
statusCounts[g.status] = g._count
|
|
}
|
|
|
|
return {
|
|
projects,
|
|
total,
|
|
page,
|
|
perPage,
|
|
totalPages: Math.ceil(total / perPage),
|
|
statusCounts,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* List all project IDs matching filters (no pagination).
|
|
* Used for "select all across pages" in bulk operations.
|
|
*/
|
|
listAllIds: adminProcedure
|
|
.input(
|
|
z.object({
|
|
programId: z.string().optional(),
|
|
roundId: z.string().optional(),
|
|
excludeInRoundId: z.string().optional(),
|
|
unassignedOnly: z.boolean().optional(),
|
|
search: z.string().optional(),
|
|
statuses: z.array(
|
|
z.enum([
|
|
'SUBMITTED',
|
|
'ELIGIBLE',
|
|
'ASSIGNED',
|
|
'SEMIFINALIST',
|
|
'FINALIST',
|
|
'REJECTED',
|
|
])
|
|
).optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
|
oceanIssue: z.enum([
|
|
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
|
|
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
|
|
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
|
|
'OCEAN_ACIDIFICATION', 'OTHER',
|
|
]).optional(),
|
|
country: z.string().optional(),
|
|
wantsMentorship: z.boolean().optional(),
|
|
hasFiles: z.boolean().optional(),
|
|
hasAssignments: z.boolean().optional(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const {
|
|
programId, roundId, excludeInRoundId, unassignedOnly,
|
|
search, statuses, tags,
|
|
competitionCategory, oceanIssue, country,
|
|
wantsMentorship, hasFiles, hasAssignments,
|
|
} = input
|
|
|
|
const where: Record<string, unknown> = {}
|
|
|
|
if (programId) where.programId = programId
|
|
if (roundId) {
|
|
where.projectRoundStates = { some: { roundId } }
|
|
}
|
|
if (excludeInRoundId) {
|
|
where.projectRoundStates = { none: { roundId: excludeInRoundId } }
|
|
}
|
|
if (unassignedOnly) {
|
|
where.projectRoundStates = { none: {} }
|
|
}
|
|
if (statuses?.length) where.status = { in: statuses }
|
|
if (tags && tags.length > 0) where.tags = { hasSome: tags }
|
|
if (competitionCategory) where.competitionCategory = competitionCategory
|
|
if (oceanIssue) where.oceanIssue = oceanIssue
|
|
if (country) where.country = country
|
|
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
|
|
if (hasFiles === true) where.files = { some: {} }
|
|
if (hasFiles === false) where.files = { none: {} }
|
|
if (hasAssignments === true) where.assignments = { some: {} }
|
|
if (hasAssignments === false) where.assignments = { none: {} }
|
|
if (search) {
|
|
where.OR = [
|
|
{ title: { contains: search, mode: 'insensitive' } },
|
|
{ teamName: { contains: search, mode: 'insensitive' } },
|
|
{ description: { contains: search, mode: 'insensitive' } },
|
|
]
|
|
}
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where,
|
|
select: { id: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
|
|
return { ids: projects.map((p) => p.id) }
|
|
}),
|
|
|
|
/**
|
|
* Preview project-team recipients before bulk status update notifications.
|
|
* Used by admin UI confirmation dialog to verify notification audience.
|
|
*/
|
|
previewStatusNotificationRecipients: adminProcedure
|
|
.input(
|
|
z.object({
|
|
ids: z.array(z.string()).min(1).max(10000),
|
|
status: z.enum([
|
|
'SUBMITTED',
|
|
'ELIGIBLE',
|
|
'ASSIGNED',
|
|
'SEMIFINALIST',
|
|
'FINALIST',
|
|
'REJECTED',
|
|
]),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const statusTriggersNotification = STATUSES_WITH_TEAM_NOTIFICATIONS.includes(
|
|
input.status as (typeof STATUSES_WITH_TEAM_NOTIFICATIONS)[number]
|
|
)
|
|
|
|
if (!statusTriggersNotification) {
|
|
return {
|
|
status: input.status,
|
|
statusTriggersNotification,
|
|
totalProjects: 0,
|
|
projectsWithRecipients: 0,
|
|
totalRecipients: 0,
|
|
projects: [] as Array<{
|
|
id: string
|
|
title: string
|
|
recipientCount: number
|
|
recipientsPreview: string[]
|
|
hasMoreRecipients: boolean
|
|
}>,
|
|
}
|
|
}
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { id: { in: input.ids } },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamMembers: {
|
|
select: {
|
|
userId: true,
|
|
user: {
|
|
select: {
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: { title: 'asc' },
|
|
})
|
|
|
|
const MAX_PREVIEW_RECIPIENTS_PER_PROJECT = 8
|
|
|
|
const mappedProjects = projects.map((project) => {
|
|
const uniqueEmails = Array.from(
|
|
new Set(
|
|
project.teamMembers
|
|
.map((member) => member.user?.email?.toLowerCase().trim() ?? '')
|
|
.filter((email) => email.length > 0)
|
|
)
|
|
)
|
|
|
|
return {
|
|
id: project.id,
|
|
title: project.title,
|
|
recipientCount: uniqueEmails.length,
|
|
recipientsPreview: uniqueEmails.slice(0, MAX_PREVIEW_RECIPIENTS_PER_PROJECT),
|
|
hasMoreRecipients: uniqueEmails.length > MAX_PREVIEW_RECIPIENTS_PER_PROJECT,
|
|
}
|
|
})
|
|
|
|
const projectsWithRecipients = mappedProjects.filter((p) => p.recipientCount > 0).length
|
|
const totalRecipients = mappedProjects.reduce((sum, project) => sum + project.recipientCount, 0)
|
|
|
|
return {
|
|
status: input.status,
|
|
statusTriggersNotification,
|
|
totalProjects: mappedProjects.length,
|
|
projectsWithRecipients,
|
|
totalRecipients,
|
|
projects: mappedProjects,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get filter options for the project list (distinct values)
|
|
*/
|
|
getFilterOptions: protectedProcedure
|
|
.query(async ({ ctx }) => {
|
|
const [countries, categories, issues] = await Promise.all([
|
|
ctx.prisma.project.findMany({
|
|
where: { country: { not: null } },
|
|
select: { country: true },
|
|
distinct: ['country'],
|
|
orderBy: { country: 'asc' },
|
|
}),
|
|
ctx.prisma.project.groupBy({
|
|
by: ['competitionCategory'],
|
|
where: { competitionCategory: { not: null } },
|
|
_count: true,
|
|
}),
|
|
ctx.prisma.project.groupBy({
|
|
by: ['oceanIssue'],
|
|
where: { oceanIssue: { not: null } },
|
|
_count: true,
|
|
}),
|
|
])
|
|
|
|
return {
|
|
countries: countries.map((c) => c.country).filter(Boolean) as string[],
|
|
categories: categories.map((c) => ({
|
|
value: c.competitionCategory!,
|
|
count: c._count,
|
|
})),
|
|
issues: issues.map((i) => ({
|
|
value: i.oceanIssue!,
|
|
count: i._count,
|
|
})),
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get a single project with details
|
|
*/
|
|
get: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
where: { id: input.id },
|
|
include: {
|
|
files: true,
|
|
teamMembers: {
|
|
include: {
|
|
user: {
|
|
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
|
|
},
|
|
},
|
|
orderBy: { joinedAt: 'asc' },
|
|
},
|
|
mentorAssignment: {
|
|
include: {
|
|
mentor: {
|
|
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Fetch project tags separately (table may not exist if migrations are pending)
|
|
let projectTags: { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[] = []
|
|
try {
|
|
projectTags = await ctx.prisma.projectTag.findMany({
|
|
where: { projectId: input.id },
|
|
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
|
|
orderBy: { confidence: 'desc' },
|
|
})
|
|
} catch {
|
|
// ProjectTag table may not exist yet
|
|
}
|
|
|
|
// Check access for jury members
|
|
if (ctx.user.role === 'JURY_MEMBER') {
|
|
const assignment = await ctx.prisma.assignment.findFirst({
|
|
where: {
|
|
projectId: input.id,
|
|
userId: ctx.user.id,
|
|
},
|
|
})
|
|
|
|
if (!assignment) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You are not assigned to this project',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Attach avatar URLs to team members and mentor
|
|
const teamMembersWithAvatars = await Promise.all(
|
|
project.teamMembers.map(async (member) => ({
|
|
...member,
|
|
user: {
|
|
...member.user,
|
|
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
|
|
},
|
|
}))
|
|
)
|
|
|
|
const mentorWithAvatar = project.mentorAssignment
|
|
? {
|
|
...project.mentorAssignment,
|
|
mentor: {
|
|
...project.mentorAssignment.mentor,
|
|
avatarUrl: await getUserAvatarUrl(
|
|
project.mentorAssignment.mentor.profileImageKey,
|
|
project.mentorAssignment.mentor.profileImageProvider
|
|
),
|
|
},
|
|
}
|
|
: null
|
|
|
|
return {
|
|
...project,
|
|
projectTags,
|
|
teamMembers: teamMembersWithAvatars,
|
|
mentorAssignment: mentorWithAvatar,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Create a single project (admin only)
|
|
* Projects belong to a program.
|
|
*/
|
|
create: adminProcedure
|
|
.input(
|
|
z.object({
|
|
programId: z.string(),
|
|
title: z.string().min(1).max(500),
|
|
teamName: z.string().optional(),
|
|
description: z.string().optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
country: z.string().optional(),
|
|
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
|
oceanIssue: z.enum([
|
|
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
|
|
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
|
|
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
|
|
'OCEAN_ACIDIFICATION', 'OTHER',
|
|
]).optional(),
|
|
institution: z.string().optional(),
|
|
contactPhone: z.string().optional(),
|
|
contactEmail: z.string().email('Invalid email address').optional(),
|
|
contactName: z.string().optional(),
|
|
city: z.string().optional(),
|
|
metadataJson: z.record(z.unknown()).optional(),
|
|
teamMembers: z.array(z.object({
|
|
name: z.string().min(1),
|
|
email: z.string().email(),
|
|
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
|
|
title: z.string().optional(),
|
|
phone: z.string().optional(),
|
|
sendInvite: z.boolean().default(false),
|
|
})).max(10).optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const {
|
|
metadataJson,
|
|
contactPhone, contactEmail, contactName, city,
|
|
teamMembers: teamMembersInput,
|
|
...rest
|
|
} = input
|
|
|
|
const resolvedProgramId = input.programId
|
|
|
|
// Build metadata from contact fields + any additional metadata
|
|
const fullMetadata: Record<string, unknown> = { ...metadataJson }
|
|
if (contactPhone) fullMetadata.contactPhone = contactPhone
|
|
if (contactEmail) fullMetadata.contactEmail = contactEmail
|
|
if (contactName) fullMetadata.contactName = contactName
|
|
if (city) fullMetadata.city = city
|
|
|
|
// Normalize country to ISO code if provided
|
|
const normalizedCountry = input.country
|
|
? normalizeCountryToCode(input.country)
|
|
: undefined
|
|
|
|
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
|
|
const created = await tx.project.create({
|
|
data: {
|
|
programId: resolvedProgramId,
|
|
title: input.title,
|
|
teamName: input.teamName,
|
|
description: input.description,
|
|
tags: input.tags || [],
|
|
country: normalizedCountry,
|
|
competitionCategory: input.competitionCategory,
|
|
oceanIssue: input.oceanIssue,
|
|
institution: input.institution,
|
|
metadataJson: Object.keys(fullMetadata).length > 0
|
|
? (fullMetadata as Prisma.InputJsonValue)
|
|
: undefined,
|
|
status: 'SUBMITTED',
|
|
},
|
|
})
|
|
|
|
// Create team members if provided
|
|
const inviteList: { userId: string; email: string; name: string }[] = []
|
|
if (teamMembersInput && teamMembersInput.length > 0) {
|
|
for (const member of teamMembersInput) {
|
|
// Find or create user
|
|
let user = await tx.user.findUnique({
|
|
where: { email: member.email.toLowerCase() },
|
|
select: { id: true, status: true },
|
|
})
|
|
|
|
if (!user) {
|
|
user = await tx.user.create({
|
|
data: {
|
|
email: member.email.toLowerCase(),
|
|
name: member.name,
|
|
role: 'APPLICANT',
|
|
status: 'NONE',
|
|
phoneNumber: member.phone || null,
|
|
},
|
|
select: { id: true, status: true },
|
|
})
|
|
}
|
|
|
|
// Create TeamMember link (skip if already linked)
|
|
await tx.teamMember.upsert({
|
|
where: {
|
|
projectId_userId: {
|
|
projectId: created.id,
|
|
userId: user.id,
|
|
},
|
|
},
|
|
create: {
|
|
projectId: created.id,
|
|
userId: user.id,
|
|
role: member.role,
|
|
title: member.title || null,
|
|
},
|
|
update: {
|
|
role: member.role,
|
|
title: member.title || null,
|
|
},
|
|
})
|
|
|
|
if (member.sendInvite) {
|
|
inviteList.push({ userId: user.id, email: member.email.toLowerCase(), name: member.name })
|
|
}
|
|
}
|
|
}
|
|
|
|
return { project: created, membersToInvite: inviteList }
|
|
})
|
|
|
|
// Audit outside transaction so failures don't roll back the project creation
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'Project',
|
|
entityId: project.id,
|
|
detailsJson: {
|
|
title: input.title,
|
|
programId: resolvedProgramId,
|
|
teamMembersCount: teamMembersInput?.length || 0,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
// Send invite emails outside the transaction (never fail project creation)
|
|
if (membersToInvite.length > 0) {
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
|
for (const member of membersToInvite) {
|
|
try {
|
|
const token = crypto.randomBytes(32).toString('hex')
|
|
await ctx.prisma.user.update({
|
|
where: { id: member.userId },
|
|
data: {
|
|
status: 'INVITED',
|
|
inviteToken: token,
|
|
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
|
},
|
|
})
|
|
|
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
|
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
|
|
|
|
// Log notification
|
|
try {
|
|
await ctx.prisma.notificationLog.create({
|
|
data: {
|
|
userId: member.userId,
|
|
channel: 'EMAIL',
|
|
type: 'JURY_INVITATION',
|
|
status: 'SENT',
|
|
},
|
|
})
|
|
} catch {
|
|
// Never fail on notification logging
|
|
}
|
|
} catch {
|
|
// Email sending failure should not break project creation
|
|
console.error(`Failed to send invite to ${member.email}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
return project
|
|
}),
|
|
|
|
/**
|
|
* Update a project (admin only)
|
|
*/
|
|
update: adminProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
title: z.string().min(1).max(500).optional(),
|
|
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: z
|
|
.enum([
|
|
'SUBMITTED',
|
|
'ELIGIBLE',
|
|
'ASSIGNED',
|
|
'SEMIFINALIST',
|
|
'FINALIST',
|
|
'REJECTED',
|
|
])
|
|
.optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
metadataJson: z.record(z.unknown()).optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { id, metadataJson, status, country, ...data } = input
|
|
|
|
// Normalize country to ISO-2 code if provided
|
|
const normalizedCountry = country !== undefined
|
|
? (country === null ? null : normalizeCountryToCode(country))
|
|
: undefined
|
|
|
|
// Validate status transition if status is being changed
|
|
if (status) {
|
|
const currentProject = await ctx.prisma.project.findUniqueOrThrow({
|
|
where: { id },
|
|
select: { status: true },
|
|
})
|
|
const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
|
|
if (!allowedTransitions.includes(status)) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
const project = await ctx.prisma.$transaction(async (tx) => {
|
|
const updated = await tx.project.update({
|
|
where: { id },
|
|
data: {
|
|
...data,
|
|
...(status && { status }),
|
|
...(normalizedCountry !== undefined && { country: normalizedCountry }),
|
|
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
|
},
|
|
})
|
|
|
|
// Record status change in history
|
|
if (status) {
|
|
await tx.projectStatusHistory.create({
|
|
data: {
|
|
projectId: id,
|
|
status,
|
|
changedBy: ctx.user.id,
|
|
},
|
|
})
|
|
}
|
|
|
|
return updated
|
|
})
|
|
|
|
// Send notifications if status changed
|
|
if (status) {
|
|
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,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'Project',
|
|
entityId: id,
|
|
detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return project
|
|
}),
|
|
|
|
/**
|
|
* Delete a project (admin only)
|
|
*/
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const target = await ctx.prisma.project.findUniqueOrThrow({
|
|
where: { id: input.id },
|
|
select: { id: true, title: true, status: true },
|
|
})
|
|
|
|
const protectedStatuses = ['FINALIST', 'SEMIFINALIST']
|
|
if (protectedStatuses.includes(target.status)) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: `Cannot delete a project with status ${target.status}. Change status first.`,
|
|
})
|
|
}
|
|
|
|
const project = await ctx.prisma.project.delete({
|
|
where: { id: input.id },
|
|
})
|
|
|
|
// Audit outside transaction so failures don't roll back the delete
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'DELETE',
|
|
entityType: 'Project',
|
|
entityId: input.id,
|
|
detailsJson: { title: target.title },
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return project
|
|
}),
|
|
|
|
/**
|
|
* Bulk delete projects (admin only)
|
|
*/
|
|
bulkDelete: adminProcedure
|
|
.input(
|
|
z.object({
|
|
ids: z.array(z.string()).min(1).max(200),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { id: { in: input.ids } },
|
|
select: { id: true, title: true, status: true },
|
|
})
|
|
|
|
if (projects.length === 0) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'No projects found to delete',
|
|
})
|
|
}
|
|
|
|
const protectedProjects = projects.filter((p) =>
|
|
['FINALIST', 'SEMIFINALIST'].includes(p.status)
|
|
)
|
|
if (protectedProjects.length > 0) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: `Cannot delete ${protectedProjects.length} project(s) with FINALIST/SEMIFINALIST status. Remove them from the selection first.`,
|
|
})
|
|
}
|
|
|
|
const result = await ctx.prisma.project.deleteMany({
|
|
where: { id: { in: projects.map((p) => p.id) } },
|
|
})
|
|
|
|
// Audit outside transaction so failures don't roll back the bulk delete
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'BULK_DELETE',
|
|
entityType: 'Project',
|
|
detailsJson: {
|
|
count: projects.length,
|
|
titles: projects.map((p) => p.title),
|
|
ids: projects.map((p) => p.id),
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return { deleted: result.count }
|
|
}),
|
|
|
|
/**
|
|
* Import projects from CSV data (admin only)
|
|
* Projects belong to a program.
|
|
*/
|
|
importCSV: adminProcedure
|
|
.input(
|
|
z.object({
|
|
programId: z.string(),
|
|
projects: z.array(
|
|
z.object({
|
|
title: z.string().min(1),
|
|
teamName: z.string().optional(),
|
|
description: z.string().optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
metadataJson: z.record(z.unknown()).optional(),
|
|
})
|
|
),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Verify program exists
|
|
await ctx.prisma.program.findUniqueOrThrow({
|
|
where: { id: input.programId },
|
|
})
|
|
|
|
// Create projects in a transaction
|
|
const result = await ctx.prisma.$transaction(async (tx) => {
|
|
const projectData = input.projects.map((p) => {
|
|
const { metadataJson, ...rest } = p
|
|
return {
|
|
...rest,
|
|
programId: input.programId,
|
|
status: 'SUBMITTED' as const,
|
|
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
|
}
|
|
})
|
|
|
|
const created = await tx.project.createManyAndReturn({
|
|
data: projectData,
|
|
select: { id: true },
|
|
})
|
|
|
|
return { imported: created.length }
|
|
})
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'IMPORT',
|
|
entityType: 'Project',
|
|
detailsJson: { programId: input.programId, count: result.imported },
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return result
|
|
}),
|
|
|
|
/**
|
|
* Get all unique tags used in projects
|
|
*/
|
|
getTags: protectedProcedure
|
|
.input(z.object({
|
|
programId: z.string().optional(),
|
|
}))
|
|
.query(async ({ ctx, input }) => {
|
|
const where: Record<string, unknown> = {}
|
|
if (input.programId) where.programId = input.programId
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: Object.keys(where).length > 0 ? where : undefined,
|
|
select: { tags: true },
|
|
})
|
|
|
|
const allTags = projects.flatMap((p) => p.tags)
|
|
const uniqueTags = [...new Set(allTags)].sort()
|
|
|
|
return uniqueTags
|
|
}),
|
|
|
|
/**
|
|
* Update project status in bulk (admin only)
|
|
*/
|
|
bulkUpdateStatus: adminProcedure
|
|
.input(
|
|
z.object({
|
|
ids: z.array(z.string()),
|
|
status: z.enum([
|
|
'SUBMITTED',
|
|
'ELIGIBLE',
|
|
'ASSIGNED',
|
|
'SEMIFINALIST',
|
|
'FINALIST',
|
|
'REJECTED',
|
|
]),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Fetch matching projects BEFORE update so notifications match actually-updated records
|
|
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 } },
|
|
select: { id: true, title: true, status: true },
|
|
})
|
|
const invalidTransitions: string[] = []
|
|
for (const p of projectsWithStatus) {
|
|
const allowed = VALID_PROJECT_TRANSITIONS[p.status] || []
|
|
if (!allowed.includes(input.status)) {
|
|
invalidTransitions.push(`"${p.title}" (${p.status} → ${input.status})`)
|
|
}
|
|
}
|
|
if (invalidTransitions.length > 0) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Invalid transitions for ${invalidTransitions.length} project(s): ${invalidTransitions.slice(0, 3).join('; ')}${invalidTransitions.length > 3 ? ` and ${invalidTransitions.length - 3} more` : ''}`,
|
|
})
|
|
}
|
|
|
|
const updated = await ctx.prisma.$transaction(async (tx) => {
|
|
const result = await tx.project.updateMany({
|
|
where: { id: { in: matchingIds } },
|
|
data: { status: input.status },
|
|
})
|
|
|
|
if (matchingIds.length > 0) {
|
|
await tx.projectStatusHistory.createMany({
|
|
data: matchingIds.map((projectId) => ({
|
|
projectId,
|
|
status: input.status,
|
|
changedBy: ctx.user.id,
|
|
})),
|
|
})
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
// Audit outside transaction so failures don't roll back the bulk update
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'BULK_UPDATE_STATUS',
|
|
entityType: 'Project',
|
|
detailsJson: { ids: matchingIds, status: input.status, count: updated.count },
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
// Notify project teams based on status
|
|
if (projects.length > 0) {
|
|
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,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return { updated: updated.count }
|
|
}),
|
|
|
|
/**
|
|
* List projects in a program's pool (not assigned to any stage)
|
|
*/
|
|
listPool: adminProcedure
|
|
.input(
|
|
z.object({
|
|
programId: z.string(),
|
|
search: z.string().optional(),
|
|
page: z.number().int().min(1).default(1),
|
|
perPage: z.number().int().min(1).max(100).default(50),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const { programId, search, page, perPage } = input
|
|
const skip = (page - 1) * perPage
|
|
|
|
const where: Record<string, unknown> = {
|
|
programId,
|
|
projectRoundStates: { none: {} }, // Projects not assigned to any round
|
|
}
|
|
|
|
if (search) {
|
|
where.OR = [
|
|
{ title: { contains: search, mode: 'insensitive' } },
|
|
{ teamName: { contains: search, mode: 'insensitive' } },
|
|
]
|
|
}
|
|
|
|
const [projects, total] = await Promise.all([
|
|
ctx.prisma.project.findMany({
|
|
where,
|
|
skip,
|
|
take: perPage,
|
|
orderBy: { createdAt: 'desc' },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
country: true,
|
|
competitionCategory: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
ctx.prisma.project.count({ where }),
|
|
])
|
|
|
|
return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) }
|
|
}),
|
|
|
|
/**
|
|
* Get full project detail with assignments and evaluation stats in one call.
|
|
* Reduces client-side waterfall by combining project.get + assignment.listByProject + evaluation.getProjectStats.
|
|
*/
|
|
getFullDetail: adminProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([
|
|
ctx.prisma.project.findUniqueOrThrow({
|
|
where: { id: input.id },
|
|
include: {
|
|
files: true,
|
|
teamMembers: {
|
|
include: {
|
|
user: {
|
|
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
|
|
},
|
|
},
|
|
orderBy: { joinedAt: 'asc' },
|
|
},
|
|
mentorAssignment: {
|
|
include: {
|
|
mentor: {
|
|
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
ctx.prisma.projectTag.findMany({
|
|
where: { projectId: input.id },
|
|
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
|
|
orderBy: { confidence: 'desc' },
|
|
}).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]),
|
|
ctx.prisma.assignment.findMany({
|
|
where: { projectId: input.id },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
|
|
round: { select: { id: true, name: true } },
|
|
evaluation: { select: { id: true, status: true, submittedAt: true, globalScore: true, binaryDecision: true, criterionScoresJson: true, feedbackText: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
status: 'SUBMITTED',
|
|
assignment: { projectId: input.id },
|
|
},
|
|
}),
|
|
])
|
|
|
|
// Compute evaluation stats
|
|
let stats = null
|
|
if (submittedEvaluations.length > 0) {
|
|
const globalScores = submittedEvaluations
|
|
.map((e) => e.globalScore)
|
|
.filter((s): s is number => s !== null)
|
|
|
|
// Count recommendations: check binaryDecision first, fall back to boolean criteria
|
|
const yesVotes = submittedEvaluations.filter((e) => {
|
|
if (e.binaryDecision != null) return e.binaryDecision === true
|
|
const scores = e.criterionScoresJson as Record<string, unknown> | null
|
|
if (!scores) return false
|
|
const boolValues = Object.values(scores).filter((v) => typeof v === 'boolean')
|
|
return boolValues.length > 0 && boolValues.every((v) => v === true)
|
|
}).length
|
|
|
|
const hasRecommendationData = submittedEvaluations.some((e) => {
|
|
if (e.binaryDecision != null) return true
|
|
const scores = e.criterionScoresJson as Record<string, unknown> | null
|
|
if (!scores) return false
|
|
return Object.values(scores).some((v) => typeof v === 'boolean')
|
|
})
|
|
|
|
stats = {
|
|
totalEvaluations: submittedEvaluations.length,
|
|
averageGlobalScore: globalScores.length > 0
|
|
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
|
|
: null,
|
|
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
|
|
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
|
|
yesVotes,
|
|
noVotes: submittedEvaluations.length - yesVotes,
|
|
yesPercentage: hasRecommendationData
|
|
? (yesVotes / submittedEvaluations.length) * 100
|
|
: null,
|
|
}
|
|
}
|
|
|
|
// Attach avatar URLs in parallel
|
|
const [teamMembersWithAvatars, assignmentsWithAvatars, mentorWithAvatar] = await Promise.all([
|
|
Promise.all(
|
|
projectRaw.teamMembers.map(async (member) => ({
|
|
...member,
|
|
user: {
|
|
...member.user,
|
|
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
|
|
},
|
|
}))
|
|
),
|
|
Promise.all(
|
|
assignments.map(async (a) => ({
|
|
...a,
|
|
user: {
|
|
...a.user,
|
|
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
|
|
},
|
|
}))
|
|
),
|
|
projectRaw.mentorAssignment
|
|
? (async () => ({
|
|
...projectRaw.mentorAssignment!,
|
|
mentor: {
|
|
...projectRaw.mentorAssignment!.mentor,
|
|
avatarUrl: await getUserAvatarUrl(
|
|
projectRaw.mentorAssignment!.mentor.profileImageKey,
|
|
projectRaw.mentorAssignment!.mentor.profileImageProvider
|
|
),
|
|
},
|
|
}))()
|
|
: Promise.resolve(null),
|
|
])
|
|
|
|
return {
|
|
project: {
|
|
...projectRaw,
|
|
projectTags,
|
|
teamMembers: teamMembersWithAvatars,
|
|
mentorAssignment: mentorWithAvatar,
|
|
},
|
|
assignments: assignmentsWithAvatars,
|
|
stats,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Create a new project and assign it directly to a round.
|
|
* Used for late-arriving projects that need to enter a specific round immediately.
|
|
*/
|
|
createAndAssignToRound: adminProcedure
|
|
.input(
|
|
z.object({
|
|
title: z.string().min(1).max(500),
|
|
teamName: z.string().optional(),
|
|
description: z.string().optional(),
|
|
country: z.string().optional(),
|
|
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
|
roundId: z.string(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { roundId, country, ...projectFields } = input
|
|
|
|
// Get the round to find competitionId, then competition to find programId
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: roundId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
competition: {
|
|
select: {
|
|
id: true,
|
|
programId: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Normalize country to ISO code if provided
|
|
const normalizedCountry = country
|
|
? normalizeCountryToCode(country)
|
|
: undefined
|
|
|
|
const project = await ctx.prisma.$transaction(async (tx) => {
|
|
// 1. Create the project
|
|
const created = await tx.project.create({
|
|
data: {
|
|
programId: round.competition.programId,
|
|
title: projectFields.title,
|
|
teamName: projectFields.teamName,
|
|
description: projectFields.description,
|
|
country: normalizedCountry,
|
|
competitionCategory: projectFields.competitionCategory,
|
|
status: 'ASSIGNED',
|
|
},
|
|
})
|
|
|
|
// 2. Create ProjectRoundState entry
|
|
await tx.projectRoundState.create({
|
|
data: {
|
|
projectId: created.id,
|
|
roundId,
|
|
state: 'PENDING',
|
|
},
|
|
})
|
|
|
|
// 3. Create ProjectStatusHistory entry
|
|
await tx.projectStatusHistory.create({
|
|
data: {
|
|
projectId: created.id,
|
|
status: 'ASSIGNED',
|
|
changedBy: ctx.user.id,
|
|
},
|
|
})
|
|
|
|
return created
|
|
})
|
|
|
|
// Audit outside transaction
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'CREATE_AND_ASSIGN',
|
|
entityType: 'Project',
|
|
entityId: project.id,
|
|
detailsJson: {
|
|
title: input.title,
|
|
roundId,
|
|
roundName: round.name,
|
|
programId: round.competition.programId,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return project
|
|
}),
|
|
})
|