From d02b0b91b9f9fb183c3586380fcf67bc6c6f6330 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 17 Feb 2026 22:05:58 +0100 Subject: [PATCH] Award shortlist UX improvements + configurable invite link expiry 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 --- .../admin/round/award-shortlist.tsx | 80 ++++++++++++++++--- .../settings/security-settings-form.tsx | 22 +++++ src/components/settings/settings-content.tsx | 1 + src/lib/email.ts | 19 +++-- src/server/routers/specialAward.ts | 22 +++++ src/server/routers/user.ts | 36 +++++++-- 6 files changed, 156 insertions(+), 24 deletions(-) diff --git a/src/components/admin/round/award-shortlist.tsx b/src/components/admin/round/award-shortlist.tsx index 533950d..9783486 100644 --- a/src/components/admin/round/award-shortlist.tsx +++ b/src/components/admin/round/award-shortlist.tsx @@ -4,7 +4,6 @@ import { useState } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { Progress } from '@/components/ui/progress' @@ -26,15 +25,14 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible' import { - Award, ChevronDown, ChevronUp, Loader2, CheckCircle2, Play, - Star, Trophy, AlertTriangle, + ChevronsUpDown, } from 'lucide-react' type AwardShortlistProps = { @@ -61,6 +59,7 @@ export function AwardShortlist({ jobDone, }: AwardShortlistProps) { const [expanded, setExpanded] = useState(false) + const [expandedReasoning, setExpandedReasoning] = useState>(new Set()) const utils = trpc.useUtils() const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING' @@ -93,6 +92,15 @@ export function AwardShortlist({ 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( { awardId }, { enabled: expanded && eligibilityMode === 'SEPARATE_POOL' } @@ -119,6 +127,27 @@ export function AwardShortlist({ : 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 ( @@ -257,23 +286,41 @@ export function AwardShortlist({ # Project Score - Reasoning - Shortlist + Reasoning + +
+ + All +
+ {shortlist.eligibilities.map((e, i) => { const reasoning = (e.aiReasoningJson as Record)?.reasoning as string | undefined + const isTop5 = i < shortlistSize + const isReasoningExpanded = expandedReasoning.has(e.id) return ( - + - {i + 1} + {isTop5 ? ( + {i + 1} + ) : ( + i + 1 + )}
-

{e.project.title}

+

+ {e.project.title} +

- {e.project.teamName || e.project.country || e.project.competitionCategory || '—'} + {[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}

@@ -290,9 +337,18 @@ export function AwardShortlist({ {reasoning ? ( -

- {reasoning} -

+ ) : ( )} diff --git a/src/components/settings/security-settings-form.tsx b/src/components/settings/security-settings-form.tsx index 6ec8801..358ebc3 100644 --- a/src/components/settings/security-settings-form.tsx +++ b/src/components/settings/security-settings-form.tsx @@ -21,6 +21,7 @@ import { const formSchema = z.object({ session_duration_hours: 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'), }) @@ -30,6 +31,7 @@ interface SecuritySettingsFormProps { settings: { session_duration_hours?: string magic_link_expiry_minutes?: string + invite_link_expiry_hours?: string rate_limit_requests_per_minute?: string } } @@ -42,6 +44,7 @@ export function SecuritySettingsForm({ settings }: SecuritySettingsFormProps) { defaultValues: { session_duration_hours: settings.session_duration_hours || '24', 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', }, }) @@ -61,6 +64,7 @@ export function SecuritySettingsForm({ settings }: SecuritySettingsFormProps) { settings: [ { key: 'session_duration_hours', value: data.session_duration_hours }, { 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 }, ], }) @@ -105,6 +109,24 @@ export function SecuritySettingsForm({ settings }: SecuritySettingsFormProps) { )} /> + ( + + Invitation Link Expiry (hours) + + + + + How long invitation links sent to new users remain valid. + Default: 72 hours (3 days). Maximum: 720 hours (30 days). + + + + )} + /> + ${roleLabel}.`)} ${paragraph('Click the button below to set up your account and get started.')} ${ctaButton(url, 'Accept Invitation')} - ${infoBox('This link will expire in 7 days.', 'info')} + ${infoBox(`This link will expire in ${expiryLabel}.`, 'info')} ` return { @@ -318,7 +326,7 @@ Click the link below to set up your account and get started: ${url} -This link will expire in 7 days. +This link will expire in ${expiryLabel}. --- Monaco Ocean Protection Challenge @@ -1581,9 +1589,10 @@ export async function sendInvitationEmail( email: string, name: string | null, url: string, - role: string + role: string, + expiryHours?: number ): Promise { - const template = getGenericInvitationTemplate(name || '', url, role) + const template = getGenericInvitationTemplate(name || '', url, role, expiryHours) const { transporter, from } = await getTransporter() await transporter.sendMail({ diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index a45ca25..41b7dd0 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -972,6 +972,28 @@ export const specialAwardRouter = router({ 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 */ diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index ac62936..dacf895 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -8,7 +8,24 @@ import { hashPassword, validatePassword } from '@/lib/password' import { attachAvatarUrls } from '@/server/utils/avatar-url' 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 { + 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 { + return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000 +} function generateInviteToken(): string { return crypto.randomBytes(32).toString('hex') @@ -795,6 +812,8 @@ export const userRouter = router({ if (input.sendInvitation) { 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) { try { @@ -803,12 +822,12 @@ export const userRouter = router({ where: { id: user.id }, data: { inviteToken: token, - inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), + inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, }) 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({ data: { @@ -937,12 +956,13 @@ export const userRouter = router({ // Generate invite token, set status to INVITED, and store on user const token = generateInviteToken() + const expiryHours = await getInviteExpiryHours(ctx.prisma) await ctx.prisma.user.update({ where: { id: user.id }, data: { status: 'INVITED', 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}` // Send invitation email - await sendInvitationEmail(user.email, user.name, inviteUrl, user.role) + await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours) // Log notification await ctx.prisma.notificationLog.create({ @@ -996,6 +1016,8 @@ export const userRouter = router({ } const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const expiryHours = await getInviteExpiryHours(ctx.prisma) + const expiryMs = expiryHours * 60 * 60 * 1000 let sent = 0 const errors: string[] = [] @@ -1008,12 +1030,12 @@ export const userRouter = router({ data: { status: 'INVITED', inviteToken: token, - inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS), + inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, }) 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({ data: {