Award shortlist UX improvements + configurable invite link expiry
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user