Award shortlist UX improvements + configurable invite link expiry
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s

Award shortlist:
- Expandable reasoning text (click to toggle, hover hint)
- Bulk select/deselect all checkbox in header
- Top N projects highlighted with amber background
- New bulkToggleShortlisted backend mutation

Invite link expiry:
- New "Invitation Link Expiry (hours)" field in Security Settings
- Reads from systemSettings `invite_link_expiry_hours` (default 72h / 3 days)
- Email template dynamically shows "X hours" or "X days" based on setting
- All 3 invite paths (bulk create, single invite, bulk resend) use setting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:05:58 +01:00
parent 8a7da0fd93
commit d02b0b91b9
6 changed files with 156 additions and 24 deletions

View File

@@ -4,7 +4,6 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
@@ -26,15 +25,14 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { import {
Award,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Loader2, Loader2,
CheckCircle2, CheckCircle2,
Play, Play,
Star,
Trophy, Trophy,
AlertTriangle, AlertTriangle,
ChevronsUpDown,
} from 'lucide-react' } from 'lucide-react'
type AwardShortlistProps = { type AwardShortlistProps = {
@@ -61,6 +59,7 @@ export function AwardShortlist({
jobDone, jobDone,
}: AwardShortlistProps) { }: AwardShortlistProps) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set())
const utils = trpc.useUtils() const utils = trpc.useUtils()
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING' const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
@@ -93,6 +92,15 @@ export function AwardShortlist({
onError: (err) => toast.error(`Failed: ${err.message}`), onError: (err) => toast.error(`Failed: ${err.message}`),
}) })
const bulkToggleMutation = trpc.specialAward.bulkToggleShortlisted.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(`${data.updated} projects ${data.shortlisted ? 'added to' : 'removed from'} shortlist`)
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery( const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
{ awardId }, { awardId },
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' } { enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
@@ -119,6 +127,27 @@ export function AwardShortlist({
: 0 : 0
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0 const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
const someShortlisted = shortlistedCount > 0 && !allShortlisted
const toggleReasoning = (id: string) => {
setExpandedReasoning((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleBulkToggle = () => {
if (!shortlist) return
const projectIds = shortlist.eligibilities.map((e) => e.project.id)
const newValue = !allShortlisted
bulkToggleMutation.mutate({ awardId, projectIds, shortlisted: newValue })
}
return ( return (
<Collapsible open={expanded} onOpenChange={setExpanded}> <Collapsible open={expanded} onOpenChange={setExpanded}>
@@ -257,23 +286,41 @@ export function AwardShortlist({
<th className="px-3 py-2 text-left w-8">#</th> <th className="px-3 py-2 text-left w-8">#</th>
<th className="px-3 py-2 text-left">Project</th> <th className="px-3 py-2 text-left">Project</th>
<th className="px-3 py-2 text-left w-24">Score</th> <th className="px-3 py-2 text-left w-24">Score</th>
<th className="px-3 py-2 text-left w-32">Reasoning</th> <th className="px-3 py-2 text-left w-44">Reasoning</th>
<th className="px-3 py-2 text-center w-20">Shortlist</th> <th className="px-3 py-2 text-center w-20">
<div className="flex items-center justify-center gap-1">
<Checkbox
checked={allShortlisted ? true : someShortlisted ? 'indeterminate' : false}
onCheckedChange={handleBulkToggle}
disabled={bulkToggleMutation.isPending}
aria-label="Select all"
/>
<span className="text-xs">All</span>
</div>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{shortlist.eligibilities.map((e, i) => { {shortlist.eligibilities.map((e, i) => {
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
const isTop5 = i < shortlistSize
const isReasoningExpanded = expandedReasoning.has(e.id)
return ( return (
<tr key={e.id} className={`border-t ${e.shortlisted ? 'bg-amber-50/50' : ''}`}> <tr key={e.id} className={`border-t ${isTop5 ? 'bg-amber-50/50' : ''}`}>
<td className="px-3 py-2 text-muted-foreground font-mono"> <td className="px-3 py-2 text-muted-foreground font-mono">
{i + 1} {isTop5 ? (
<span className="text-amber-600 font-semibold">{i + 1}</span>
) : (
i + 1
)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div> <div>
<p className="font-medium">{e.project.title}</p> <p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}>
{e.project.title}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{e.project.teamName || e.project.country || e.project.competitionCategory || '—'} {[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}
</p> </p>
</div> </div>
</td> </td>
@@ -290,9 +337,18 @@ export function AwardShortlist({
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{reasoning ? ( {reasoning ? (
<p className="text-xs text-muted-foreground line-clamp-2" title={reasoning}> <button
{reasoning} onClick={() => toggleReasoning(e.id)}
</p> className="text-left w-full group"
>
<p className={`text-xs text-muted-foreground ${isReasoningExpanded ? '' : 'line-clamp-2'}`}>
{reasoning}
</p>
<span className="text-xs text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5 mt-0.5">
<ChevronsUpDown className="h-3 w-3" />
{isReasoningExpanded ? 'Collapse' : 'Expand'}
</span>
</button>
) : ( ) : (
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
)} )}

View File

@@ -21,6 +21,7 @@ import {
const formSchema = z.object({ const formSchema = z.object({
session_duration_hours: z.string().regex(/^\d+$/, 'Must be a number'), session_duration_hours: z.string().regex(/^\d+$/, 'Must be a number'),
magic_link_expiry_minutes: z.string().regex(/^\d+$/, 'Must be a number'), magic_link_expiry_minutes: z.string().regex(/^\d+$/, 'Must be a number'),
invite_link_expiry_hours: z.string().regex(/^\d+$/, 'Must be a number'),
rate_limit_requests_per_minute: z.string().regex(/^\d+$/, 'Must be a number'), rate_limit_requests_per_minute: z.string().regex(/^\d+$/, 'Must be a number'),
}) })
@@ -30,6 +31,7 @@ interface SecuritySettingsFormProps {
settings: { settings: {
session_duration_hours?: string session_duration_hours?: string
magic_link_expiry_minutes?: string magic_link_expiry_minutes?: string
invite_link_expiry_hours?: string
rate_limit_requests_per_minute?: string rate_limit_requests_per_minute?: string
} }
} }
@@ -42,6 +44,7 @@ export function SecuritySettingsForm({ settings }: SecuritySettingsFormProps) {
defaultValues: { defaultValues: {
session_duration_hours: settings.session_duration_hours || '24', session_duration_hours: settings.session_duration_hours || '24',
magic_link_expiry_minutes: settings.magic_link_expiry_minutes || '15', magic_link_expiry_minutes: settings.magic_link_expiry_minutes || '15',
invite_link_expiry_hours: settings.invite_link_expiry_hours || '72',
rate_limit_requests_per_minute: settings.rate_limit_requests_per_minute || '60', rate_limit_requests_per_minute: settings.rate_limit_requests_per_minute || '60',
}, },
}) })
@@ -61,6 +64,7 @@ export function SecuritySettingsForm({ settings }: SecuritySettingsFormProps) {
settings: [ settings: [
{ key: 'session_duration_hours', value: data.session_duration_hours }, { key: 'session_duration_hours', value: data.session_duration_hours },
{ key: 'magic_link_expiry_minutes', value: data.magic_link_expiry_minutes }, { key: 'magic_link_expiry_minutes', value: data.magic_link_expiry_minutes },
{ key: 'invite_link_expiry_hours', value: data.invite_link_expiry_hours },
{ key: 'rate_limit_requests_per_minute', value: data.rate_limit_requests_per_minute }, { key: 'rate_limit_requests_per_minute', value: data.rate_limit_requests_per_minute },
], ],
}) })
@@ -105,6 +109,24 @@ export function SecuritySettingsForm({ settings }: SecuritySettingsFormProps) {
)} )}
/> />
<FormField
control={form.control}
name="invite_link_expiry_hours"
render={({ field }) => (
<FormItem>
<FormLabel>Invitation Link Expiry (hours)</FormLabel>
<FormControl>
<Input type="number" min="1" max="720" placeholder="72" {...field} />
</FormControl>
<FormDescription>
How long invitation links sent to new users remain valid.
Default: 72 hours (3 days). Maximum: 720 hours (30 days).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="rate_limit_requests_per_minute" name="rate_limit_requests_per_minute"

View File

@@ -115,6 +115,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
const securitySettings = getSettingsByKeys([ const securitySettings = getSettingsByKeys([
'session_duration_hours', 'session_duration_hours',
'magic_link_expiry_minutes', 'magic_link_expiry_minutes',
'invite_link_expiry_hours',
'rate_limit_requests_per_minute', 'rate_limit_requests_per_minute',
]) ])

View File

@@ -289,21 +289,29 @@ Together for a healthier ocean.
/** /**
* Generate generic invitation email template (not round-specific) * Generate generic invitation email template (not round-specific)
*/ */
function formatExpiryLabel(hours: number): string {
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''}`
const days = Math.round(hours / 24)
return `${days} day${days !== 1 ? 's' : ''}`
}
function getGenericInvitationTemplate( function getGenericInvitationTemplate(
name: string, name: string,
url: string, url: string,
role: string role: string,
expiryHours: number = 72
): EmailTemplate { ): EmailTemplate {
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ') const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
const article = /^[aeiou]/i.test(roleLabel) ? 'an' : 'a' const article = /^[aeiou]/i.test(roleLabel) ? 'an' : 'a'
const greeting = name ? `Hello ${name},` : 'Hello,' const greeting = name ? `Hello ${name},` : 'Hello,'
const expiryLabel = formatExpiryLabel(expiryHours)
const content = ` const content = `
${sectionTitle(greeting)} ${sectionTitle(greeting)}
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} <strong>${roleLabel}</strong>.`)} ${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} <strong>${roleLabel}</strong>.`)}
${paragraph('Click the button below to set up your account and get started.')} ${paragraph('Click the button below to set up your account and get started.')}
${ctaButton(url, 'Accept Invitation')} ${ctaButton(url, 'Accept Invitation')}
${infoBox('This link will expire in 7 days.', 'info')} ${infoBox(`This link will expire in ${expiryLabel}.`, 'info')}
` `
return { return {
@@ -318,7 +326,7 @@ Click the link below to set up your account and get started:
${url} ${url}
This link will expire in 7 days. This link will expire in ${expiryLabel}.
--- ---
Monaco Ocean Protection Challenge Monaco Ocean Protection Challenge
@@ -1581,9 +1589,10 @@ export async function sendInvitationEmail(
email: string, email: string,
name: string | null, name: string | null,
url: string, url: string,
role: string role: string,
expiryHours?: number
): Promise<void> { ): Promise<void> {
const template = getGenericInvitationTemplate(name || '', url, role) const template = getGenericInvitationTemplate(name || '', url, role, expiryHours)
const { transporter, from } = await getTransporter() const { transporter, from } = await getTransporter()
await transporter.sendMail({ await transporter.sendMail({

View File

@@ -972,6 +972,28 @@ export const specialAwardRouter = router({
return { shortlisted: updated.shortlisted } return { shortlisted: updated.shortlisted }
}), }),
/**
* Bulk toggle shortlisted status for multiple projects
*/
bulkToggleShortlisted: adminProcedure
.input(z.object({
awardId: z.string(),
projectIds: z.array(z.string()),
shortlisted: z.boolean(),
}))
.mutation(async ({ ctx, input }) => {
const result = await ctx.prisma.awardEligibility.updateMany({
where: {
awardId: input.awardId,
projectId: { in: input.projectIds },
eligible: true,
},
data: { shortlisted: input.shortlisted },
})
return { updated: result.count, shortlisted: input.shortlisted }
}),
/** /**
* Confirm shortlist — for SEPARATE_POOL awards, creates ProjectRoundState entries * Confirm shortlist — for SEPARATE_POOL awards, creates ProjectRoundState entries
*/ */

View File

@@ -8,7 +8,24 @@ import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url' import { attachAvatarUrls } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days const DEFAULT_INVITE_EXPIRY_HOURS = 72 // 3 days
async function getInviteExpiryHours(prisma: import('@prisma/client').PrismaClient): Promise<number> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'invite_link_expiry_hours' },
select: { value: true },
})
const hours = setting?.value ? parseInt(setting.value, 10) : DEFAULT_INVITE_EXPIRY_HOURS
return isNaN(hours) || hours < 1 ? DEFAULT_INVITE_EXPIRY_HOURS : hours
} catch {
return DEFAULT_INVITE_EXPIRY_HOURS
}
}
async function getInviteExpiryMs(prisma: import('@prisma/client').PrismaClient): Promise<number> {
return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000
}
function generateInviteToken(): string { function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex') return crypto.randomBytes(32).toString('hex')
@@ -795,6 +812,8 @@ export const userRouter = router({
if (input.sendInvitation) { if (input.sendInvitation) {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const expiryHours = await getInviteExpiryHours(ctx.prisma)
const expiryMs = expiryHours * 60 * 60 * 1000
for (const user of createdUsers) { for (const user of createdUsers) {
try { try {
@@ -803,12 +822,12 @@ export const userRouter = router({
where: { id: user.id }, where: { id: user.id },
data: { data: {
inviteToken: token, inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
}, },
}) })
const inviteUrl = `${baseUrl}/accept-invite?token=${token}` const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role) await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
await ctx.prisma.notificationLog.create({ await ctx.prisma.notificationLog.create({
data: { data: {
@@ -937,12 +956,13 @@ export const userRouter = router({
// Generate invite token, set status to INVITED, and store on user // Generate invite token, set status to INVITED, and store on user
const token = generateInviteToken() const token = generateInviteToken()
const expiryHours = await getInviteExpiryHours(ctx.prisma)
await ctx.prisma.user.update({ await ctx.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
status: 'INVITED', status: 'INVITED',
inviteToken: token, inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
}, },
}) })
@@ -950,7 +970,7 @@ export const userRouter = router({
const inviteUrl = `${baseUrl}/accept-invite?token=${token}` const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// Send invitation email // Send invitation email
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role) await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
// Log notification // Log notification
await ctx.prisma.notificationLog.create({ await ctx.prisma.notificationLog.create({
@@ -996,6 +1016,8 @@ export const userRouter = router({
} }
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const expiryHours = await getInviteExpiryHours(ctx.prisma)
const expiryMs = expiryHours * 60 * 60 * 1000
let sent = 0 let sent = 0
const errors: string[] = [] const errors: string[] = []
@@ -1008,12 +1030,12 @@ export const userRouter = router({
data: { data: {
status: 'INVITED', status: 'INVITED',
inviteToken: token, inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
}, },
}) })
const inviteUrl = `${baseUrl}/accept-invite?token=${token}` const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role) await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
await ctx.prisma.notificationLog.create({ await ctx.prisma.notificationLog.create({
data: { data: {