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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string[]> = {
|
||||
@@ -81,17 +85,23 @@ export const projectRouter = router({
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
// 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<string, unknown> = {}
|
||||
|
||||
if (programId) where.programId = programId
|
||||
if (roundId) where.roundId = roundId
|
||||
if (notInRoundId) {
|
||||
if (!where.AND) where.AND = []
|
||||
;(where.AND as unknown[]).push({
|
||||
OR: [
|
||||
{ roundId: null },
|
||||
{ roundId: { not: notInRoundId } },
|
||||
],
|
||||
})
|
||||
}
|
||||
if (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
|
||||
}),
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user