From 5c8d22ac113bdea07cd5b9826c4c1f5c1f65d551 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 10 Feb 2026 23:07:38 +0100 Subject: [PATCH] Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes - Round detail: add skeleton loading for filtering stats, inline results table with expandable rows, pagination, override/reinstate, CSV export, and tooltip on AI summaries button (removes need for separate results page) - Projects: add select-all-across-pages with Gmail-style banner, show country flags with tooltip instead of country codes (table + card views), add listAllIds backend endpoint - Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only - Members: add inline role change via dropdown submenu in user actions, enforce role hierarchy (only super admins can modify admin/super-admin roles) in both backend and UI Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/members/[id]/page.tsx | 24 +- src/app/(admin)/admin/projects/page.tsx | 154 ++++- src/app/(admin)/admin/rounds/[id]/page.tsx | 606 ++++++++++++++++++- src/app/(admin)/admin/settings/page.tsx | 19 +- src/components/admin/members-content.tsx | 16 +- src/components/admin/user-actions.tsx | 154 ++++- src/components/settings/settings-content.tsx | 249 ++++---- src/server/routers/project.ts | 210 ++++++- src/server/routers/user.ts | 22 +- 9 files changed, 1257 insertions(+), 197 deletions(-) diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index 326096e..e1a3691 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -74,7 +74,7 @@ export default function MemberDetailPage() { const [name, setName] = useState('') const [role, setRole] = useState('JURY_MEMBER') - const [status, setStatus] = useState('INVITED') + const [status, setStatus] = useState('NONE') const [expertiseTags, setExpertiseTags] = useState([]) const [maxAssignments, setMaxAssignments] = useState('') const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false) @@ -96,7 +96,7 @@ export default function MemberDetailPage() { id: userId, name: name || null, role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN', - status: status as 'INVITED' | 'ACTIVE' | 'SUSPENDED', + status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED', expertiseTags, maxAssignments: maxAssignments ? parseInt(maxAssignments) : null, }) @@ -180,11 +180,11 @@ export default function MemberDetailPage() {

{user.email}

- {user.status} + {user.status === 'NONE' ? 'Not Invited' : user.status}
- {user.status === 'INVITED' && ( + {(user.status === 'NONE' || user.status === 'INVITED') && ( + + )} + {allMatchingSelected && data && ( +
+ + + All {selectedIds.size} matching projects are selected. + + +
+ )} + {/* Content */} {isLoading ? ( @@ -728,9 +837,23 @@ export default function ProjectsPage() {

{project.teamName} - {project.country && ( - · {project.country} - )} + {project.country && (() => { + const code = normalizeCountryToCode(project.country) + const flag = code ? getCountryFlag(code) : null + const name = code ? getCountryName(code) : project.country + return flag ? ( + + + + · {flag} + +

{name}

+ + + ) : ( + · {project.country} + ) + })()}

@@ -983,9 +1106,23 @@ export default function ProjectsPage() { {project.teamName} - {project.country && ( - · {project.country} - )} + {project.country && (() => { + const code = normalizeCountryToCode(project.country) + const flag = code ? getCountryFlag(code) : null + const name = code ? getCountryName(code) : project.country + return flag ? ( + + + + · {flag} + +

{name}

+
+
+ ) : ( + · {project.country} + ) + })()}
@@ -1118,10 +1255,7 @@ export default function ProjectsPage() { + ))} + + + + + {/* Results Table */} + {filteringResults && filteringResults.results.length > 0 ? ( + <> +
+ + + + Project + Category + Outcome + AI Reason + Actions + + + + {filteringResults.results.map((result) => { + const isExpanded = expandedRows.has(result.id) + const effectiveOutcome = + result.finalOutcome || result.outcome + const badge = OUTCOME_BADGES[effectiveOutcome] + + const aiScreening = result.aiScreeningJson as Record | null + const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null + const aiReasoning = firstAiResult?.reasoning + + return ( + <> + toggleResultRow(result.id)} + > + +
+

+ {result.project.title} +

+

+ {result.project.teamName} + {result.project.country && ` · ${result.project.country}`} +

+
+
+ + {result.project.competitionCategory ? ( + + {result.project.competitionCategory.replace( + '_', + ' ' + )} + + ) : ( + '-' + )} + + +
+ + {badge?.icon} + {badge?.label || effectiveOutcome} + + {result.overriddenByUser && ( +

+ Overridden by {result.overriddenByUser.name || result.overriddenByUser.email} +

+ )} +
+
+ + {aiReasoning ? ( +
+

+ {aiReasoning} +

+ {firstAiResult && ( +
+ {firstAiResult.confidence !== undefined && ( + Confidence: {Math.round(firstAiResult.confidence * 100)}% + )} + {firstAiResult.qualityScore !== undefined && ( + Quality: {firstAiResult.qualityScore}/10 + )} + {firstAiResult.spamRisk && ( + Spam Risk + )} +
+ )} +
+ ) : ( + + No AI screening + + )} +
+ +
e.stopPropagation()} + > + + {effectiveOutcome === 'FILTERED_OUT' && ( + + )} +
+
+
+ {isExpanded && ( + + +
+ {/* Rule Results */} +
+

+ Rule Results +

+ {result.ruleResultsJson && + Array.isArray(result.ruleResultsJson) ? ( +
+ {( + result.ruleResultsJson as Array<{ + ruleName: string + ruleType: string + passed: boolean + action: string + reasoning?: string + }> + ).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => ( +
+ {rr.passed ? ( + + ) : ( + + )} +
+
+ + {rr.ruleName} + + + {rr.ruleType.replace('_', ' ')} + +
+ {rr.reasoning && ( +

+ {rr.reasoning} +

+ )} +
+
+ ))} +
+ ) : ( +

+ No detailed rule results available +

+ )} +
+ + {/* AI Screening Details */} + {aiScreening && Object.keys(aiScreening).length > 0 && ( +
+

+ AI Screening Analysis +

+
+ {Object.entries(aiScreening).map(([ruleId, screening]) => ( +
+
+ {screening.meetsCriteria ? ( + + ) : ( + + )} + + {screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'} + + {screening.spamRisk && ( + + + Spam Risk + + )} +
+ {screening.reasoning && ( +

+ {screening.reasoning} +

+ )} +
+ {screening.confidence !== undefined && ( + + Confidence: {Math.round(screening.confidence * 100)}% + + )} + {screening.qualityScore !== undefined && ( + + Quality Score: {screening.qualityScore}/10 + + )} +
+
+ ))} +
+
+ )} + + {/* Override Info */} + {result.overriddenByUser && ( +
+

Manual Override

+

+ Overridden to {result.finalOutcome} by{' '} + {result.overriddenByUser.name || result.overriddenByUser.email} +

+ {result.overrideReason && ( +

+ Reason: {result.overrideReason} +

+ )} +
+ )} +
+
+
+ )} + + ) + })} +
+
+
+ + + + ) : filteringResults ? ( +
+ +

No results match this filter

+
+ ) : null} + + + )} + {/* Quick links */}
- {filteringStats && filteringStats.total > 0 && ( - + + + + + + +

Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.

+
+
+
+ + + + + + {/* CSV Export Dialog */} +
) } diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index 01a9bc7..bb38cac 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -8,15 +8,22 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { SettingsContent } from '@/components/settings/settings-content' -async function SettingsLoader() { +// Categories that only super admins can access +const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY']) + +async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) { const settings = await prisma.systemSettings.findMany({ orderBy: [{ category: 'asc' }, { key: 'asc' }], }) // Convert settings array to key-value map // For secrets, pass a marker but not the actual value + // For non-super-admins, filter out infrastructure categories const settingsMap: Record = {} settings.forEach((setting) => { + if (!isSuperAdmin && SUPER_ADMIN_CATEGORIES.has(setting.category)) { + return + } if (setting.isSecret && setting.value) { // Pass marker for UI to show "existing" state settingsMap[setting.key] = '********' @@ -25,7 +32,7 @@ async function SettingsLoader() { } }) - return + return } function SettingsSkeleton() { @@ -52,11 +59,13 @@ function SettingsSkeleton() { export default async function SettingsPage() { const session = await auth() - // Only super admins can access settings - if (session?.user?.role !== 'SUPER_ADMIN') { + // Only admins (super admin + program admin) can access settings + if (session?.user?.role !== 'SUPER_ADMIN' && session?.user?.role !== 'PROGRAM_ADMIN') { redirect('/admin') } + const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN' + return (
{/* Header */} @@ -69,7 +78,7 @@ export default async function SettingsPage() { {/* Content */} }> - +
) diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index f247cf2..e3f5b09 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -42,11 +42,16 @@ const TAB_ROLES: Record = { } const statusColors: Record = { + NONE: 'secondary', ACTIVE: 'success', INVITED: 'secondary', SUSPENDED: 'destructive', } +const statusLabels: Record = { + NONE: 'Not Invited', +} + const roleColors: Record = { JURY_MEMBER: 'default', MENTOR: 'secondary', @@ -92,6 +97,9 @@ export function MembersContent() { const roles = TAB_ROLES[tab] + const { data: currentUser } = trpc.user.me.useQuery() + const currentUserRole = currentUser?.role as RoleValue | undefined + const { data, isLoading } = trpc.user.list.useQuery({ roles: roles, search: search || undefined, @@ -216,7 +224,7 @@ export function MembersContent() { - {user.status} + {statusLabels[user.status] || user.status} @@ -233,6 +241,8 @@ export function MembersContent() { userId={user.id} userEmail={user.email} userStatus={user.status} + userRole={user.role as RoleValue} + currentUserRole={currentUserRole} /> @@ -263,7 +273,7 @@ export function MembersContent() { - {user.status} + {statusLabels[user.status] || user.status} @@ -305,6 +315,8 @@ export function MembersContent() { userId={user.id} userEmail={user.email} userStatus={user.status} + userRole={user.role as RoleValue} + currentUserRole={currentUserRole} />
diff --git a/src/components/admin/user-actions.tsx b/src/components/admin/user-actions.tsx index 658d2cb..75b44af 100644 --- a/src/components/admin/user-actions.tsx +++ b/src/components/admin/user-actions.tsx @@ -9,6 +9,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -28,15 +31,29 @@ import { UserCog, Trash2, Loader2, + Shield, + Check, } from 'lucide-react' +type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' + +const ROLE_LABELS: Record = { + SUPER_ADMIN: 'Super Admin', + PROGRAM_ADMIN: 'Program Admin', + JURY_MEMBER: 'Jury Member', + MENTOR: 'Mentor', + OBSERVER: 'Observer', +} + interface UserActionsProps { userId: string userEmail: string userStatus: string + userRole: Role + currentUserRole?: Role } -export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) { +export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isSending, setIsSending] = useState(false) @@ -44,13 +61,40 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) const sendInvitation = trpc.user.sendInvitation.useMutation() const deleteUser = trpc.user.delete.useMutation({ onSuccess: () => { - // Invalidate user list to refresh the members table utils.user.list.invalidate() }, }) + const updateUser = trpc.user.update.useMutation({ + onSuccess: () => { + utils.user.list.invalidate() + toast.success('Role updated successfully') + }, + onError: (error) => { + toast.error(error.message || 'Failed to update role') + }, + }) + + const isSuperAdmin = currentUserRole === 'SUPER_ADMIN' + + // Determine which roles can be assigned + const getAvailableRoles = (): Role[] => { + if (isSuperAdmin) { + return ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] + } + // Program admins can only assign lower roles + return ['JURY_MEMBER', 'MENTOR', 'OBSERVER'] + } + + // Can this user's role be changed by the current user? + const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole)) + + const handleRoleChange = (newRole: Role) => { + if (newRole === userRole) return + updateUser.mutate({ id: userId, role: newRole }) + } const handleSendInvitation = async () => { - if (userStatus !== 'INVITED') { + if (userStatus !== 'NONE' && userStatus !== 'INVITED') { toast.error('User has already accepted their invitation') return } @@ -98,9 +142,31 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) Edit + {canChangeRole && ( + + + + {updateUser.isPending ? 'Updating...' : 'Change Role'} + + + {getAvailableRoles().map((role) => ( + handleRoleChange(role)} + disabled={role === userRole} + > + {role === userRole && } + + {ROLE_LABELS[role]} + + + ))} + + + )} {isSending ? 'Sending...' : 'Send Invite'} @@ -147,18 +213,35 @@ interface UserMobileActionsProps { userId: string userEmail: string userStatus: string + userRole: Role + currentUserRole?: Role } export function UserMobileActions({ userId, userEmail, userStatus, + userRole, + currentUserRole, }: UserMobileActionsProps) { const [isSending, setIsSending] = useState(false) + const utils = trpc.useUtils() const sendInvitation = trpc.user.sendInvitation.useMutation() + const updateUser = trpc.user.update.useMutation({ + onSuccess: () => { + utils.user.list.invalidate() + toast.success('Role updated successfully') + }, + onError: (error) => { + toast.error(error.message || 'Failed to update role') + }, + }) + + const isSuperAdmin = currentUserRole === 'SUPER_ADMIN' + const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole)) const handleSendInvitation = async () => { - if (userStatus !== 'INVITED') { + if (userStatus !== 'NONE' && userStatus !== 'INVITED') { toast.error('User has already accepted their invitation') return } @@ -175,27 +258,46 @@ export function UserMobileActions({ } return ( -
- - +
+
+ + +
+ {canChangeRole && ( + + )}
) } diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index c8528e2..8ce4fec 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -61,9 +61,10 @@ function SettingsSkeleton() { interface SettingsContentProps { initialSettings: Record + isSuperAdmin?: boolean } -export function SettingsContent({ initialSettings }: SettingsContentProps) { +export function SettingsContent({ initialSettings, isSuperAdmin = true }: SettingsContentProps) { // We use the initial settings passed from the server // Forms will refetch on mutation success @@ -168,10 +169,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) { Locale - - - Email - + {isSuperAdmin && ( + + + Email + + )} Notif. @@ -180,18 +183,22 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) { Digest - - - Security - + {isSuperAdmin && ( + + + Security + + )} Audit - - - AI - + {isSuperAdmin && ( + + + AI + + )} Tags @@ -200,10 +207,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) { Analytics - - - Storage - + {isSuperAdmin && ( + + + Storage + + )}
@@ -230,10 +239,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {

Communication

- - - Email - + {isSuperAdmin && ( + + + Email + + )} Notifications @@ -247,10 +258,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {

Security

- - - Security - + {isSuperAdmin && ( + + + Security + + )} Audit @@ -260,10 +273,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {

Features

- - - AI - + {isSuperAdmin && ( + + + AI + + )} Tags @@ -274,35 +289,39 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
-
-

Infrastructure

- - - - Storage - - -
+ {isSuperAdmin && ( +
+

Infrastructure

+ + + + Storage + + +
+ )}
{/* Content area */}
- - - - AI Configuration - - Configure AI-powered features like smart jury assignment - - - - - - - - + {isSuperAdmin && ( + + + + AI Configuration + + Configure AI-powered features like smart jury assignment + + + + + + + + + )} @@ -350,19 +369,21 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) { - - - - Email Configuration - - Configure email settings for notifications and magic links - - - - - - - + {isSuperAdmin && ( + + + + Email Configuration + + Configure email settings for notifications and magic links + + + + + + + + )} @@ -378,33 +399,37 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) { - - - - File Storage - - Configure file upload limits and allowed types - - - - - - - + {isSuperAdmin && ( + + + + File Storage + + Configure file upload limits and allowed types + + + + + + + + )} - - - - Security Settings - - Configure security and access control settings - - - - - - - + {isSuperAdmin && ( + + + + Security Settings + + Configure security and access control settings + + + + + + + + )} @@ -502,26 +527,28 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) { - - - - - Webhooks - - - Configure webhook endpoints for platform events - - - - - - + {isSuperAdmin && ( + + + + + Webhooks + + + Configure webhook endpoints for platform events + + + + + + + )}
) diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index df68c44..5b819e8 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto' import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' @@ -9,6 +10,9 @@ import { } 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 // Valid project status transitions const VALID_PROJECT_TRANSITIONS: Record = { @@ -81,17 +85,23 @@ export const projectRouter = router({ // Build where clause const where: Record = {} - // Filter by program via round - if (programId) where.round = { programId } + // Filter by program + if (programId) where.programId = programId // Filter by round if (roundId) { where.roundId = roundId } - // Exclude projects in a specific round + // Exclude projects in a specific round (include unassigned projects with roundId=null) if (notInRoundId) { - where.roundId = { not: notInRoundId } + if (!where.AND) where.AND = [] + ;(where.AND as unknown[]).push({ + OR: [ + { roundId: null }, + { roundId: { not: notInRoundId } }, + ], + }) } // Filter by unassigned (no round) @@ -164,6 +174,91 @@ export const projectRouter = router({ } }), + /** + * 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(), + notInRoundId: 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, notInRoundId, unassignedOnly, + search, statuses, tags, + competitionCategory, oceanIssue, country, + wantsMentorship, hasFiles, hasAssignments, + } = input + + const where: Record = {} + + if (programId) where.programId = programId + if (roundId) where.roundId = roundId + if (notInRoundId) { + if (!where.AND) where.AND = [] + ;(where.AND as unknown[]).push({ + OR: [ + { roundId: null }, + { roundId: { not: notInRoundId } }, + ], + }) + } + if (unassignedOnly) where.roundId = null + if (statuses?.length) where.status = { in: statuses } + if (tags && tags.length > 0) where.tags = { hasSome: tags } + if (competitionCategory) where.competitionCategory = competitionCategory + 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) } + }), + /** * Get filter options for the project list (distinct values) */ @@ -318,12 +413,21 @@ export const projectRouter = router({ 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 @@ -349,7 +453,7 @@ export const projectRouter = router({ ? normalizeCountryToCode(input.country) : undefined - const project = await ctx.prisma.$transaction(async (tx) => { + const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => { const created = await tx.project.create({ data: { programId: resolvedProgramId, @@ -369,20 +473,112 @@ export const projectRouter = router({ }, }) + // 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 }) + } + } + } + await logAudit({ prisma: tx, userId: ctx.user.id, action: 'CREATE', entityType: 'Project', entityId: created.id, - detailsJson: { title: input.title, roundId: input.roundId, programId: resolvedProgramId }, + detailsJson: { + title: input.title, + roundId: input.roundId, + programId: resolvedProgramId, + teamMembersCount: teamMembersInput?.length || 0, + }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - return created + return { project: created, membersToInvite: inviteList } }) + // 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}/auth/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 }), diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index c15435f..923291f 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -185,7 +185,7 @@ export const userRouter = router({ z.object({ role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(), - status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(), + status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), search: z.string().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(20), @@ -340,7 +340,7 @@ export const userRouter = router({ id: z.string(), name: z.string().optional().nullable(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), - status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(), + status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), expertiseTags: z.array(z.string()).optional(), maxAssignments: z.number().int().min(1).max(100).optional().nullable(), availabilityJson: z.any().optional(), @@ -362,6 +362,14 @@ export const userRouter = router({ }) } + // Prevent non-super-admins from changing admin roles + if (data.role && targetUser.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Only super admins can change admin roles', + }) + } + // Prevent non-super-admins from assigning super admin or admin role if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ @@ -708,18 +716,19 @@ export const userRouter = router({ where: { id: input.userId }, }) - if (user.status !== 'INVITED') { + if (user.status !== 'NONE' && user.status !== 'INVITED') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'User has already accepted their invitation', }) } - // Generate invite token and store on user + // Generate invite token, set status to INVITED, and store on user const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: user.id }, data: { + status: 'INVITED', inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), }, @@ -766,7 +775,7 @@ export const userRouter = router({ const users = await ctx.prisma.user.findMany({ where: { id: { in: input.userIds }, - status: 'INVITED', + status: { in: ['NONE', 'INVITED'] }, }, }) @@ -780,11 +789,12 @@ export const userRouter = router({ for (const user of users) { try { - // Generate invite token for each user + // Generate invite token for each user and set status to INVITED const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: user.id }, data: { + status: 'INVITED', inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), },