Compare commits
2 Commits
f79a6d1341
...
1d4e31ddd1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d4e31ddd1 | |||
| 924f8071e1 |
@@ -53,11 +53,11 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -158,7 +158,7 @@ export default function AwardDetailPage({
|
|||||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||||
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
||||||
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
|
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
|
||||||
const [notifyCustomMessage, setNotifyCustomMessage] = useState('')
|
const [notifyCustomMessage, setNotifyCustomMessage] = useState<string | undefined>()
|
||||||
|
|
||||||
// Pagination for eligibility list
|
// Pagination for eligibility list
|
||||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||||
@@ -287,15 +287,15 @@ export default function AwardDetailPage({
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: notifyStats } = trpc.specialAward.getNotificationStats.useQuery(
|
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
|
||||||
{ awardId },
|
{ awardId, customMessage: notifyCustomMessage },
|
||||||
{ enabled: notifyDialogOpen }
|
{ enabled: notifyDialogOpen }
|
||||||
)
|
)
|
||||||
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
|
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
|
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
|
||||||
setNotifyDialogOpen(false)
|
setNotifyDialogOpen(false)
|
||||||
setNotifyCustomMessage('')
|
setNotifyCustomMessage(undefined)
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -486,63 +486,22 @@ export default function AwardDetailPage({
|
|||||||
)}
|
)}
|
||||||
{award.status === 'NOMINATIONS_OPEN' && (
|
{award.status === 'NOMINATIONS_OPEN' && (
|
||||||
<>
|
<>
|
||||||
<Dialog open={notifyDialogOpen} onOpenChange={setNotifyDialogOpen}>
|
<Button variant="outline" disabled={award.eligibleCount === 0} onClick={() => setNotifyDialogOpen(true)}>
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" disabled={award.eligibleCount === 0}>
|
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
Notify Pool
|
Notify Pool ({award.eligibleCount})
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
<EmailPreviewDialog
|
||||||
<DialogContent>
|
open={notifyDialogOpen}
|
||||||
<DialogHeader>
|
onOpenChange={setNotifyDialogOpen}
|
||||||
<DialogTitle>Notify Eligible Projects</DialogTitle>
|
title="Notify Eligible Projects"
|
||||||
<DialogDescription>
|
description={`Send "Selected for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`}
|
||||||
Send "Selected for {award.name}" emails to all {award.eligibleCount} eligible projects.
|
recipientCount={notifyPreview.data?.recipientCount ?? 0}
|
||||||
</DialogDescription>
|
previewHtml={notifyPreview.data?.html}
|
||||||
</DialogHeader>
|
isPreviewLoading={notifyPreview.isLoading}
|
||||||
<div className="space-y-4 py-2">
|
onSend={(msg) => notifyEligible.mutate({ awardId, customMessage: msg })}
|
||||||
{notifyStats && (
|
isSending={notifyEligible.isPending}
|
||||||
<div className="flex flex-wrap gap-2">
|
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
|
||||||
{notifyStats.needsInvite > 0 && (
|
|
||||||
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700">
|
|
||||||
{notifyStats.needsInvite} will receive Create Account link
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{notifyStats.hasAccount > 0 && (
|
|
||||||
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700">
|
|
||||||
{notifyStats.hasAccount} will receive Dashboard link
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Custom message (optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Add a personal message to include in the email..."
|
|
||||||
value={notifyCustomMessage}
|
|
||||||
onChange={(e) => setNotifyCustomMessage(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setNotifyDialogOpen(false)}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => notifyEligible.mutate({
|
|
||||||
awardId,
|
|
||||||
customMessage: notifyCustomMessage.trim() || undefined,
|
|
||||||
})}
|
|
||||||
disabled={notifyEligible.isPending}
|
|
||||||
>
|
|
||||||
{notifyEligible.isPending ? (
|
|
||||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Sending...</>
|
|
||||||
) : (
|
|
||||||
<><Mail className="mr-2 h-4 w-4" />Send {award.eligibleCount} Emails</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||||
disabled={updateStatus.isPending}
|
disabled={updateStatus.isPending}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface EmailPreviewDialogProps {
|
|||||||
isSending: boolean
|
isSending: boolean
|
||||||
showCustomMessage?: boolean
|
showCustomMessage?: boolean
|
||||||
onRefreshPreview?: (customMessage?: string) => void
|
onRefreshPreview?: (customMessage?: string) => void
|
||||||
|
previewOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailPreviewDialog({
|
export function EmailPreviewDialog({
|
||||||
@@ -40,6 +41,7 @@ export function EmailPreviewDialog({
|
|||||||
isSending,
|
isSending,
|
||||||
showCustomMessage = true,
|
showCustomMessage = true,
|
||||||
onRefreshPreview,
|
onRefreshPreview,
|
||||||
|
previewOnly = false,
|
||||||
}: EmailPreviewDialogProps) {
|
}: EmailPreviewDialogProps) {
|
||||||
const [customMessage, setCustomMessage] = useState('')
|
const [customMessage, setCustomMessage] = useState('')
|
||||||
|
|
||||||
@@ -104,6 +106,10 @@ export function EmailPreviewDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
|
{previewOnly ? (
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSending}>
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSending}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -123,6 +129,8 @@ export function EmailPreviewDialog({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Mail,
|
Mail,
|
||||||
Send,
|
Send,
|
||||||
|
Eye,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { projectStateConfig } from '@/lib/round-config'
|
import { projectStateConfig } from '@/lib/round-config'
|
||||||
|
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -81,6 +83,10 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
|
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
|
||||||
const [advancementMessage, setAdvancementMessage] = useState('')
|
const [advancementMessage, setAdvancementMessage] = useState('')
|
||||||
const [rejectionMessage, setRejectionMessage] = useState('')
|
const [rejectionMessage, setRejectionMessage] = useState('')
|
||||||
|
const [advancePreviewOpen, setAdvancePreviewOpen] = useState(false)
|
||||||
|
const [rejectPreviewOpen, setRejectPreviewOpen] = useState(false)
|
||||||
|
const [advancePreviewMsg, setAdvancePreviewMsg] = useState<string | undefined>()
|
||||||
|
const [rejectPreviewMsg, setRejectPreviewMsg] = useState<string | undefined>()
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateOutcome = trpc.roundEngine.updateProposedOutcome.useMutation({
|
const updateOutcome = trpc.roundEngine.updateProposedOutcome.useMutation({
|
||||||
@@ -121,6 +127,16 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Email preview queries
|
||||||
|
const advancePreview = trpc.roundEngine.previewFinalizationAdvancementEmail.useQuery(
|
||||||
|
{ roundId, customMessage: advancePreviewMsg },
|
||||||
|
{ enabled: advancePreviewOpen }
|
||||||
|
)
|
||||||
|
const rejectPreview = trpc.roundEngine.previewFinalizationRejectionEmail.useQuery(
|
||||||
|
{ roundId, customMessage: rejectPreviewMsg },
|
||||||
|
{ enabled: rejectPreviewOpen }
|
||||||
|
)
|
||||||
|
|
||||||
// Filtered projects
|
// Filtered projects
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (!summary) return []
|
if (!summary) return []
|
||||||
@@ -624,7 +640,21 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1.5 block">Advancement Message</label>
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="text-sm font-medium">Advancement Message</label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setAdvancePreviewMsg(advancementMessage || undefined)
|
||||||
|
setAdvancePreviewOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
|
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
|
||||||
value={advancementMessage}
|
value={advancementMessage}
|
||||||
@@ -633,7 +663,21 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1.5 block">Rejection Message</label>
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="text-sm font-medium">Rejection Message</label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setRejectPreviewMsg(rejectionMessage || undefined)
|
||||||
|
setRejectPreviewOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Custom message for projects that are not advancing (added to the standard email template)..."
|
placeholder="Custom message for projects that are not advancing (added to the standard email template)..."
|
||||||
value={rejectionMessage}
|
value={rejectionMessage}
|
||||||
@@ -718,6 +762,34 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Email Preview Dialogs */}
|
||||||
|
<EmailPreviewDialog
|
||||||
|
open={advancePreviewOpen}
|
||||||
|
onOpenChange={setAdvancePreviewOpen}
|
||||||
|
title="Advancement Email Preview"
|
||||||
|
description="Preview of the email sent to advancing project teams"
|
||||||
|
recipientCount={advancePreview.data?.recipientCount ?? passedCount}
|
||||||
|
previewHtml={advancePreview.data?.html}
|
||||||
|
isPreviewLoading={advancePreview.isLoading}
|
||||||
|
onSend={() => setAdvancePreviewOpen(false)}
|
||||||
|
isSending={false}
|
||||||
|
showCustomMessage={false}
|
||||||
|
previewOnly
|
||||||
|
/>
|
||||||
|
<EmailPreviewDialog
|
||||||
|
open={rejectPreviewOpen}
|
||||||
|
onOpenChange={setRejectPreviewOpen}
|
||||||
|
title="Rejection Email Preview"
|
||||||
|
description="Preview of the email sent to non-advancing project teams"
|
||||||
|
recipientCount={rejectPreview.data?.recipientCount ?? rejectedCount}
|
||||||
|
previewHtml={rejectPreview.data?.html}
|
||||||
|
isPreviewLoading={rejectPreview.isLoading}
|
||||||
|
onSend={() => setRejectPreviewOpen(false)}
|
||||||
|
isSending={false}
|
||||||
|
showCustomMessage={false}
|
||||||
|
previewOnly
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
|||||||
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
|
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||||
@@ -33,6 +36,7 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
|||||||
roleName="Applicant"
|
roleName="Applicant"
|
||||||
user={user}
|
user={user}
|
||||||
basePath="/applicant"
|
basePath="/applicant"
|
||||||
|
helpEmail={featureFlags?.supportEmail || undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||||||
undefined,
|
undefined,
|
||||||
{ refetchInterval: 60000 }
|
{ refetchInterval: 60000 }
|
||||||
)
|
)
|
||||||
|
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
|
||||||
|
|
||||||
|
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{
|
{
|
||||||
@@ -69,8 +72,9 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
name: 'Learning Hub',
|
name: 'Learning Hub',
|
||||||
href: '/jury/learning',
|
href: useExternal ? flags.learningHubExternalUrl : '/jury/learning',
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
|
external: !!useExternal,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
import { BookOpen, Home, Users } from 'lucide-react'
|
import { BookOpen, Home, Users } from 'lucide-react'
|
||||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
||||||
interface MentorNavProps {
|
interface MentorNavProps {
|
||||||
user: RoleNavUser
|
user: RoleNavUser
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MentorNav({ user }: MentorNavProps) {
|
export function MentorNav({ user }: MentorNavProps) {
|
||||||
|
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
|
||||||
|
|
||||||
|
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{
|
{
|
||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
@@ -21,8 +26,9 @@ export function MentorNav({ user }: MentorNavProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Learning Hub',
|
name: 'Learning Hub',
|
||||||
href: '/mentor/resources',
|
href: useExternal ? flags.learningHubExternalUrl : '/mentor/resources',
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
|
external: !!useExternal,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
LogOut, Menu, Moon, Settings, Sun, User, X,
|
LogOut, Menu, Moon, Settings, Sun, User, X,
|
||||||
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
|
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
|
||||||
|
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { UserRole } from '@prisma/client'
|
import type { UserRole } from '@prisma/client'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
@@ -30,6 +31,7 @@ export type NavItem = {
|
|||||||
name: string
|
name: string
|
||||||
href: string
|
href: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
|
external?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoleNavUser = {
|
export type RoleNavUser = {
|
||||||
@@ -47,6 +49,8 @@ type RoleNavProps = {
|
|||||||
statusBadge?: React.ReactNode
|
statusBadge?: React.ReactNode
|
||||||
/** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */
|
/** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */
|
||||||
editionSelector?: React.ReactNode
|
editionSelector?: React.ReactNode
|
||||||
|
/** Optional support email — when provided, shows a Help button in the header */
|
||||||
|
helpEmail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role switcher config — maps roles to their dashboard views
|
// Role switcher config — maps roles to their dashboard views
|
||||||
@@ -62,7 +66,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
|
|||||||
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
|
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector, helpEmail }: RoleNavProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { data: session, status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus } = useSession()
|
||||||
@@ -94,18 +98,24 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
{/* Desktop nav */}
|
{/* Desktop nav */}
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
<nav className="hidden md:flex items-center gap-1">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive = isNavItemActive(pathname, item.href, basePath)
|
const isActive = !item.external && isNavItemActive(pathname, item.href, basePath)
|
||||||
return (
|
const className = cn(
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href as Route}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
)}
|
)
|
||||||
>
|
if (item.external) {
|
||||||
|
return (
|
||||||
|
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link key={item.name} href={item.href as Route} className={className}>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -116,6 +126,28 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
{/* User menu & mobile toggle */}
|
{/* User menu & mobile toggle */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{editionSelector && <div className="hidden md:block">{editionSelector}</div>}
|
{editionSelector && <div className="hidden md:block">{editionSelector}</div>}
|
||||||
|
{helpEmail && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Help">
|
||||||
|
<HelpCircle className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<p className="text-sm font-medium">Need Help?</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Contact our support team</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href={`mailto:${helpEmail}`} className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{helpEmail}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -208,19 +240,24 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
<div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
|
<div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
|
||||||
<nav className="container-app py-4 space-y-1">
|
<nav className="container-app py-4 space-y-1">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive = isNavItemActive(pathname, item.href, basePath)
|
const isActive = !item.external && isNavItemActive(pathname, item.href, basePath)
|
||||||
return (
|
const className = cn(
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href as Route}
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
)}
|
)
|
||||||
>
|
if (item.external) {
|
||||||
|
return (
|
||||||
|
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" onClick={() => setIsMobileMenuOpen(false)} className={className}>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link key={item.name} href={item.href as Route} onClick={() => setIsMobileMenuOpen(false)} className={className}>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
'default_page_size',
|
'default_page_size',
|
||||||
'autosave_interval_seconds',
|
'autosave_interval_seconds',
|
||||||
'display_project_names_uppercase',
|
'display_project_names_uppercase',
|
||||||
|
'learning_hub_external',
|
||||||
|
'learning_hub_external_url',
|
||||||
|
'support_email',
|
||||||
])
|
])
|
||||||
|
|
||||||
const digestSettings = getSettingsByKeys([
|
const digestSettings = getSettingsByKeys([
|
||||||
@@ -432,7 +435,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TabsContent value="defaults">
|
<TabsContent value="defaults" className="space-y-6">
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -446,6 +449,20 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Platform Features</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure Learning Hub, support contact, and other platform features
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PlatformFeaturesSection settings={defaultsSettings} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="digest" className="space-y-6">
|
<TabsContent value="digest" className="space-y-6">
|
||||||
@@ -805,6 +822,37 @@ function AuditSettingsSection({ settings }: { settings: Record<string, string> }
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlatformFeaturesSection({ settings }: { settings: Record<string, string> }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="text-sm font-medium">Learning Hub</Label>
|
||||||
|
<SettingToggle
|
||||||
|
label="Use External Learning Hub"
|
||||||
|
description="When enabled, jury and mentor navigation links will open the external URL instead of the built-in Learning Hub"
|
||||||
|
settingKey="learning_hub_external"
|
||||||
|
value={settings.learning_hub_external || 'false'}
|
||||||
|
/>
|
||||||
|
<SettingInput
|
||||||
|
label="External URL"
|
||||||
|
description="The URL to redirect jury and mentor users to (e.g. Google Drive, Notion, etc.)"
|
||||||
|
settingKey="learning_hub_external_url"
|
||||||
|
value={settings.learning_hub_external_url || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-4 space-y-4">
|
||||||
|
<Label className="text-sm font-medium">Support</Label>
|
||||||
|
<SettingInput
|
||||||
|
label="Support Email"
|
||||||
|
description="Shown as a help button on the applicant page header. Leave empty to hide."
|
||||||
|
settingKey="support_email"
|
||||||
|
value={settings.support_email || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
|
function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
getFinalizationSummary,
|
getFinalizationSummary,
|
||||||
confirmFinalization,
|
confirmFinalization,
|
||||||
} from '../services/round-finalization'
|
} from '../services/round-finalization'
|
||||||
|
import {
|
||||||
|
getAdvancementNotificationTemplate,
|
||||||
|
getRejectionNotificationTemplate,
|
||||||
|
} from '@/lib/email'
|
||||||
|
|
||||||
const projectRoundStateEnum = z.enum([
|
const projectRoundStateEnum = z.enum([
|
||||||
'PENDING',
|
'PENDING',
|
||||||
@@ -367,6 +371,66 @@ export const roundEngineRouter = router({
|
|||||||
return { updated }
|
return { updated }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
previewFinalizationAdvancementEmail: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
customMessage: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
competition: {
|
||||||
|
select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const rounds = round.competition.rounds
|
||||||
|
const currentIdx = rounds.findIndex((r) => r.id === input.roundId)
|
||||||
|
const nextRound = rounds[currentIdx + 1]
|
||||||
|
const toRoundName = nextRound?.name ?? 'Next Round'
|
||||||
|
|
||||||
|
const passedCount = await ctx.prisma.projectRoundState.count({
|
||||||
|
where: { roundId: input.roundId, proposedOutcome: 'PASSED' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const template = getAdvancementNotificationTemplate(
|
||||||
|
'Team Member',
|
||||||
|
'Your Project',
|
||||||
|
round.name,
|
||||||
|
toRoundName,
|
||||||
|
input.customMessage || undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { html: template.html, subject: template.subject, recipientCount: passedCount }
|
||||||
|
}),
|
||||||
|
|
||||||
|
previewFinalizationRejectionEmail: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
customMessage: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectedCount = await ctx.prisma.projectRoundState.count({
|
||||||
|
where: { roundId: input.roundId, proposedOutcome: 'REJECTED' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const template = getRejectionNotificationTemplate(
|
||||||
|
'Team Member',
|
||||||
|
'Your Project',
|
||||||
|
round.name,
|
||||||
|
input.customMessage || undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { html: template.html, subject: template.subject, recipientCount: rejectedCount }
|
||||||
|
}),
|
||||||
|
|
||||||
confirmFinalization: adminProcedure
|
confirmFinalization: adminProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
|
|||||||
@@ -25,24 +25,47 @@ function categorizeModel(modelId: string): string {
|
|||||||
return 'other'
|
return 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferSettingCategory(key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'FEATURE_FLAGS' {
|
||||||
|
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
|
||||||
|
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
|
||||||
|
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
|
||||||
|
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
|
||||||
|
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
|
||||||
|
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
|
||||||
|
if (key.startsWith('learning_hub_') || key.startsWith('jury_compare_') || key.startsWith('support_')) return 'FEATURE_FLAGS'
|
||||||
|
return 'DEFAULTS'
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsRouter = router({
|
export const settingsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get public feature flags (no auth required)
|
* Get public feature flags (no auth required)
|
||||||
* These are non-sensitive settings that can be exposed to any user
|
* These are non-sensitive settings that can be exposed to any user
|
||||||
*/
|
*/
|
||||||
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
|
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const [whatsappEnabled, juryCompareEnabled] = await Promise.all([
|
const [whatsappEnabled, juryCompareEnabled, learningHubExternal, learningHubExternalUrl, supportEmail] = await Promise.all([
|
||||||
ctx.prisma.systemSettings.findUnique({
|
ctx.prisma.systemSettings.findUnique({
|
||||||
where: { key: 'whatsapp_enabled' },
|
where: { key: 'whatsapp_enabled' },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.systemSettings.findUnique({
|
ctx.prisma.systemSettings.findUnique({
|
||||||
where: { key: 'jury_compare_enabled' },
|
where: { key: 'jury_compare_enabled' },
|
||||||
}),
|
}),
|
||||||
|
ctx.prisma.systemSettings.findUnique({
|
||||||
|
where: { key: 'learning_hub_external' },
|
||||||
|
}),
|
||||||
|
ctx.prisma.systemSettings.findUnique({
|
||||||
|
where: { key: 'learning_hub_external_url' },
|
||||||
|
}),
|
||||||
|
ctx.prisma.systemSettings.findUnique({
|
||||||
|
where: { key: 'support_email' },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
whatsappEnabled: whatsappEnabled?.value === 'true',
|
whatsappEnabled: whatsappEnabled?.value === 'true',
|
||||||
juryCompareEnabled: juryCompareEnabled?.value === 'true',
|
juryCompareEnabled: juryCompareEnabled?.value === 'true',
|
||||||
|
learningHubExternal: learningHubExternal?.value === 'true',
|
||||||
|
learningHubExternalUrl: learningHubExternalUrl?.value || '',
|
||||||
|
supportEmail: supportEmail?.value || '',
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -120,12 +143,18 @@ export const settingsRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const setting = await ctx.prisma.systemSettings.update({
|
const setting = await ctx.prisma.systemSettings.upsert({
|
||||||
where: { key: input.key },
|
where: { key: input.key },
|
||||||
data: {
|
update: {
|
||||||
value: input.value,
|
value: input.value,
|
||||||
updatedBy: ctx.user.id,
|
updatedBy: ctx.user.id,
|
||||||
},
|
},
|
||||||
|
create: {
|
||||||
|
key: input.key,
|
||||||
|
value: input.value,
|
||||||
|
category: inferSettingCategory(input.key),
|
||||||
|
updatedBy: ctx.user.id,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clear storage provider cache when storage_provider setting changes
|
// Clear storage provider cache when storage_provider setting changes
|
||||||
@@ -161,23 +190,12 @@ export const settingsRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
category: z.enum(['AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP']).optional(),
|
category: z.enum(['AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'FEATURE_FLAGS']).optional(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Infer category from key prefix if not provided
|
|
||||||
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
|
|
||||||
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
|
|
||||||
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
|
|
||||||
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
|
|
||||||
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
|
|
||||||
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
|
|
||||||
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
|
|
||||||
return 'DEFAULTS'
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
input.settings.map((s) =>
|
input.settings.map((s) =>
|
||||||
ctx.prisma.systemSettings.upsert({
|
ctx.prisma.systemSettings.upsert({
|
||||||
@@ -189,7 +207,7 @@ export const settingsRouter = router({
|
|||||||
create: {
|
create: {
|
||||||
key: s.key,
|
key: s.key,
|
||||||
value: s.value,
|
value: s.value,
|
||||||
category: s.category || inferCategory(s.key),
|
category: s.category || inferSettingCategory(s.key),
|
||||||
updatedBy: ctx.user.id,
|
updatedBy: ctx.user.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Prisma } from '@prisma/client'
|
|||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
import { sendStyledNotificationEmail, getAwardSelectionNotificationTemplate } from '@/lib/email'
|
||||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
@@ -1229,6 +1229,31 @@ export const specialAwardRouter = router({
|
|||||||
return { needsInvite, hasAccount, totalProjects: eligibilities.length }
|
return { needsInvite, hasAccount, totalProjects: eligibilities.length }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
previewAwardSelectionEmail: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
customMessage: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
select: { name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const eligibleCount = await ctx.prisma.awardEligibility.count({
|
||||||
|
where: { awardId: input.awardId, eligible: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const template = getAwardSelectionNotificationTemplate(
|
||||||
|
'Team Member',
|
||||||
|
'Your Project',
|
||||||
|
award.name,
|
||||||
|
input.customMessage || undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { html: template.html, subject: template.subject, recipientCount: eligibleCount }
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify eligible projects that they've been selected for an award.
|
* Notify eligible projects that they've been selected for an award.
|
||||||
* Generates invite tokens for passwordless users.
|
* Generates invite tokens for passwordless users.
|
||||||
|
|||||||
Reference in New Issue
Block a user