feat: per-round advancement selection, email preview, Docker/auth fixes
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m42s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m42s
- Bulk notification dialog: per-round checkboxes (default none selected), selected count badge, "Preview Email" button with rendered iframe - Backend: roundIds filter on sendBulkPassedNotifications, new previewAdvancementEmail query - Docker: add external MinIO network so app container can reach MinIO - File router: try/catch on getPresignedUrl with descriptive error - Auth: custom NextAuth logger suppresses CredentialsSignin stack traces Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- mopc-network
|
- mopc-network
|
||||||
|
- minio-external
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -82,3 +83,6 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
mopc-network:
|
mopc-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
minio-external:
|
||||||
|
external: true
|
||||||
|
name: minio_mopc-minio
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
Trophy,
|
Trophy,
|
||||||
Ban,
|
Ban,
|
||||||
Award,
|
Award,
|
||||||
|
Eye,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface BulkNotificationDialogProps {
|
interface BulkNotificationDialogProps {
|
||||||
@@ -47,6 +50,11 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
|||||||
const [passedEnabled, setPassedEnabled] = useState(true)
|
const [passedEnabled, setPassedEnabled] = useState(true)
|
||||||
const [passedMessage, setPassedMessage] = useState('')
|
const [passedMessage, setPassedMessage] = useState('')
|
||||||
const [passedFullCustom, setPassedFullCustom] = useState(false)
|
const [passedFullCustom, setPassedFullCustom] = useState(false)
|
||||||
|
const [selectedRoundIds, setSelectedRoundIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
|
const [previewRoundId, setPreviewRoundId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Rejected section
|
// Rejected section
|
||||||
const [rejectedEnabled, setRejectedEnabled] = useState(false)
|
const [rejectedEnabled, setRejectedEnabled] = useState(false)
|
||||||
@@ -71,17 +79,49 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const preview = trpc.project.previewAdvancementEmail.useQuery(
|
||||||
|
{
|
||||||
|
roundId: previewRoundId!,
|
||||||
|
customMessage: passedMessage || undefined,
|
||||||
|
fullCustomBody: passedFullCustom,
|
||||||
|
},
|
||||||
|
{ enabled: previewOpen && !!previewRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
const sendPassed = trpc.project.sendBulkPassedNotifications.useMutation()
|
const sendPassed = trpc.project.sendBulkPassedNotifications.useMutation()
|
||||||
const sendRejected = trpc.project.sendBulkRejectionNotifications.useMutation()
|
const sendRejected = trpc.project.sendBulkRejectionNotifications.useMutation()
|
||||||
const sendAward = trpc.project.sendBulkAwardNotifications.useMutation()
|
const sendAward = trpc.project.sendBulkAwardNotifications.useMutation()
|
||||||
|
|
||||||
|
const toggleRound = useCallback((roundId: string) => {
|
||||||
|
setSelectedRoundIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(roundId)) {
|
||||||
|
next.delete(roundId)
|
||||||
|
} else {
|
||||||
|
next.add(roundId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedPassedCount = summary.data?.passed
|
||||||
|
.filter((g) => selectedRoundIds.has(g.roundId))
|
||||||
|
.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
|
||||||
|
|
||||||
|
const totalPassed = summary.data?.passed.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
|
||||||
|
|
||||||
const handleSendPassed = async () => {
|
const handleSendPassed = async () => {
|
||||||
|
if (selectedRoundIds.size === 0) {
|
||||||
|
toast.error('Select at least one round to notify')
|
||||||
|
return
|
||||||
|
}
|
||||||
setSendingPassed(true)
|
setSendingPassed(true)
|
||||||
try {
|
try {
|
||||||
const result = await sendPassed.mutateAsync({
|
const result = await sendPassed.mutateAsync({
|
||||||
customMessage: passedMessage || undefined,
|
customMessage: passedMessage || undefined,
|
||||||
fullCustomBody: passedFullCustom,
|
fullCustomBody: passedFullCustom,
|
||||||
skipAlreadySent,
|
skipAlreadySent,
|
||||||
|
roundIds: Array.from(selectedRoundIds),
|
||||||
})
|
})
|
||||||
toast.success(`Advancement: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
toast.success(`Advancement: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
||||||
summary.refetch()
|
summary.refetch()
|
||||||
@@ -130,7 +170,7 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
|||||||
const handleSendAll = async () => {
|
const handleSendAll = async () => {
|
||||||
setSendingAll(true)
|
setSendingAll(true)
|
||||||
try {
|
try {
|
||||||
if (passedEnabled && totalPassed > 0) {
|
if (passedEnabled && selectedPassedCount > 0) {
|
||||||
await handleSendPassed()
|
await handleSendPassed()
|
||||||
}
|
}
|
||||||
if (rejectedEnabled && (summary.data?.rejected.count ?? 0) > 0) {
|
if (rejectedEnabled && (summary.data?.rejected.count ?? 0) > 0) {
|
||||||
@@ -144,239 +184,315 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPassed = summary.data?.passed.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
|
const handleOpenPreview = () => {
|
||||||
|
// Use first selected round for preview context
|
||||||
|
const firstRoundId = Array.from(selectedRoundIds)[0]
|
||||||
|
if (!firstRoundId) {
|
||||||
|
toast.error('Select at least one round to preview')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreviewRoundId(firstRoundId)
|
||||||
|
setPreviewOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const isSending = sendingPassed || sendingRejected || sendingAward || sendingAll
|
const isSending = sendingPassed || sendingRejected || sendingAward || sendingAll
|
||||||
|
|
||||||
|
// Find round name for preview
|
||||||
|
const previewRoundName = summary.data?.passed.find((g) => g.roundId === previewRoundId)?.roundName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<>
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogHeader>
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
<DialogTitle>Bulk Notifications</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>
|
<DialogTitle>Bulk Notifications</DialogTitle>
|
||||||
Send advancement, rejection, and award pool notifications to project teams.
|
<DialogDescription>
|
||||||
</DialogDescription>
|
Send advancement, rejection, and award pool notifications to project teams.
|
||||||
</DialogHeader>
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
{summary.isLoading ? (
|
{summary.isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
|
||||||
) : summary.error ? (
|
|
||||||
<div className="text-destructive text-sm py-4">
|
|
||||||
Failed to load summary: {summary.error.message}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Global settings */}
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="skip-already-sent"
|
|
||||||
checked={skipAlreadySent}
|
|
||||||
onCheckedChange={setSkipAlreadySent}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="skip-already-sent" className="text-sm">
|
|
||||||
Skip already notified
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{summary.data?.alreadyNotified.advancement ?? 0} advancement + {summary.data?.alreadyNotified.rejection ?? 0} rejection already sent
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : summary.error ? (
|
||||||
{/* PASSED section */}
|
<div className="text-destructive text-sm py-4">
|
||||||
<Collapsible open={passedOpen} onOpenChange={setPassedOpen}>
|
Failed to load summary: {summary.error.message}
|
||||||
<div className="rounded-lg border">
|
</div>
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
) : (
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-4">
|
||||||
{passedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{/* Global settings */}
|
||||||
<Trophy className="h-4 w-4 text-green-600" />
|
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||||
<span className="font-medium">Passed / Advanced</span>
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">{totalPassed} projects</Badge>
|
|
||||||
</div>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={passedEnabled}
|
id="skip-already-sent"
|
||||||
onCheckedChange={setPassedEnabled}
|
checked={skipAlreadySent}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onCheckedChange={setSkipAlreadySent}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
<Label htmlFor="skip-already-sent" className="text-sm">
|
||||||
<CollapsibleContent>
|
Skip already notified
|
||||||
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
</Label>
|
||||||
{summary.data?.passed.map((g) => (
|
</div>
|
||||||
<div key={g.roundId} className="text-sm flex items-center gap-2">
|
<div className="text-xs text-muted-foreground">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
{summary.data?.alreadyNotified.advancement ?? 0} advancement + {summary.data?.alreadyNotified.rejection ?? 0} rejection already sent
|
||||||
<span className="text-muted-foreground">{g.roundName}</span>
|
</div>
|
||||||
<span className="font-medium">{g.projectCount}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">→ {g.nextRoundName}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="space-y-2 pt-2">
|
|
||||||
<Label className="text-xs">Custom message (optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
value={passedMessage}
|
|
||||||
onChange={(e) => setPassedMessage(e.target.value)}
|
|
||||||
placeholder="Add a personal note to the advancement email..."
|
|
||||||
rows={2}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="passed-full-custom"
|
|
||||||
checked={passedFullCustom}
|
|
||||||
onCheckedChange={setPassedFullCustom}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="passed-full-custom" className="text-xs">
|
|
||||||
Full custom body (replace default template)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSendPassed}
|
|
||||||
disabled={!passedEnabled || totalPassed === 0 || isSending}
|
|
||||||
>
|
|
||||||
{sendingPassed ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
|
||||||
Send Advancement
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* REJECTED section */}
|
{/* PASSED section */}
|
||||||
<Collapsible open={rejectedOpen} onOpenChange={setRejectedOpen}>
|
<Collapsible open={passedOpen} onOpenChange={setPassedOpen}>
|
||||||
<div className="rounded-lg border">
|
<div className="rounded-lg border">
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{rejectedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{passedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
<Ban className="h-4 w-4 text-red-600" />
|
<Trophy className="h-4 w-4 text-green-600" />
|
||||||
<span className="font-medium">Rejected / Filtered Out</span>
|
<span className="font-medium">Passed / Advanced</span>
|
||||||
<Badge variant="destructive">{summary.data?.rejected.count ?? 0} projects</Badge>
|
<Badge variant="secondary">
|
||||||
</div>
|
{selectedRoundIds.size > 0
|
||||||
<Switch
|
? `${selectedPassedCount} of ${totalPassed} selected`
|
||||||
checked={rejectedEnabled}
|
: `${totalPassed} projects`}
|
||||||
onCheckedChange={setRejectedEnabled}
|
</Badge>
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">Custom message (optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
value={rejectedMessage}
|
|
||||||
onChange={(e) => setRejectedMessage(e.target.value)}
|
|
||||||
placeholder="Add a personal note to the rejection email..."
|
|
||||||
rows={2}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="rejected-full-custom"
|
|
||||||
checked={rejectedFullCustom}
|
|
||||||
onCheckedChange={setRejectedFullCustom}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="rejected-full-custom" className="text-xs">
|
|
||||||
Full custom body (replace default template)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="rejected-include-invite"
|
|
||||||
checked={rejectedIncludeInvite}
|
|
||||||
onCheckedChange={setRejectedIncludeInvite}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="rejected-include-invite" className="text-xs">
|
|
||||||
Include platform invite link for rejected teams
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Switch
|
||||||
size="sm"
|
checked={passedEnabled}
|
||||||
variant="destructive"
|
onCheckedChange={setPassedEnabled}
|
||||||
onClick={handleSendRejected}
|
onClick={(e) => e.stopPropagation()}
|
||||||
disabled={!rejectedEnabled || (summary.data?.rejected.count ?? 0) === 0 || isSending}
|
/>
|
||||||
>
|
</CollapsibleTrigger>
|
||||||
{sendingRejected ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
<CollapsibleContent>
|
||||||
Send Rejections
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
</Button>
|
{summary.data?.passed.map((g) => (
|
||||||
</div>
|
<label
|
||||||
</CollapsibleContent>
|
key={g.roundId}
|
||||||
</div>
|
className="text-sm flex items-center gap-2 cursor-pointer hover:bg-muted/30 rounded px-1 py-0.5 -mx-1"
|
||||||
</Collapsible>
|
>
|
||||||
|
<Checkbox
|
||||||
{/* AWARD POOLS section */}
|
checked={selectedRoundIds.has(g.roundId)}
|
||||||
<Collapsible open={awardOpen} onOpenChange={setAwardOpen}>
|
onCheckedChange={() => toggleRound(g.roundId)}
|
||||||
<div className="rounded-lg border">
|
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{awardOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
||||||
<Award className="h-4 w-4 text-amber-600" />
|
|
||||||
<span className="font-medium">Award Pools</span>
|
|
||||||
<Badge variant="outline">{summary.data?.awardPools.length ?? 0} awards</Badge>
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
|
||||||
{(summary.data?.awardPools ?? []).length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No award pools configured.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{summary.data?.awardPools.map((a) => (
|
|
||||||
<div key={a.awardId} className="flex items-center justify-between rounded border p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Award className="h-3.5 w-3.5 text-amber-500" />
|
|
||||||
<span className="text-sm font-medium">{a.awardName}</span>
|
|
||||||
<Badge variant="secondary" className="text-xs">{a.eligibleCount} eligible</Badge>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAwardId(a.awardId)
|
|
||||||
handleSendAward(a.awardId)
|
|
||||||
}}
|
|
||||||
disabled={a.eligibleCount === 0 || isSending}
|
|
||||||
>
|
|
||||||
{sendingAward && selectedAwardId === a.awardId ? (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Notify
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="space-y-2 pt-1">
|
|
||||||
<Label className="text-xs">Custom message for awards (optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
value={awardMessage}
|
|
||||||
onChange={(e) => setAwardMessage(e.target.value)}
|
|
||||||
placeholder="Add a note to the award notification..."
|
|
||||||
rows={2}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
/>
|
||||||
|
<span className="text-muted-foreground">{g.roundName}</span>
|
||||||
|
<span className="font-medium">{g.projectCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">→ {g.nextRoundName}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{(summary.data?.passed.length ?? 0) > 0 && selectedRoundIds.size === 0 && (
|
||||||
|
<p className="text-xs text-amber-600">Select rounds above to enable sending.</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label className="text-xs">Custom message (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={passedMessage}
|
||||||
|
onChange={(e) => setPassedMessage(e.target.value)}
|
||||||
|
placeholder="Add a personal note to the advancement email..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="passed-full-custom"
|
||||||
|
checked={passedFullCustom}
|
||||||
|
onCheckedChange={setPassedFullCustom}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="passed-full-custom" className="text-xs">
|
||||||
|
Full custom body (replace default template)
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Button
|
||||||
</CollapsibleContent>
|
size="sm"
|
||||||
</div>
|
variant="outline"
|
||||||
</Collapsible>
|
onClick={handleOpenPreview}
|
||||||
|
disabled={selectedRoundIds.size === 0 || isSending}
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Preview Email
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSendPassed}
|
||||||
|
disabled={!passedEnabled || selectedRoundIds.size === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingPassed ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
Send Advancement ({selectedPassedCount})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
{/* Send All button */}
|
{/* REJECTED section */}
|
||||||
<div className="flex justify-end pt-2 border-t">
|
<Collapsible open={rejectedOpen} onOpenChange={setRejectedOpen}>
|
||||||
<Button
|
<div className="rounded-lg border">
|
||||||
onClick={handleSendAll}
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
disabled={(!passedEnabled && !rejectedEnabled) || isSending}
|
<div className="flex items-center gap-3">
|
||||||
>
|
{rejectedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
{sendingAll ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
<Ban className="h-4 w-4 text-red-600" />
|
||||||
Send All Enabled
|
<span className="font-medium">Rejected / Filtered Out</span>
|
||||||
</Button>
|
<Badge variant="destructive">{summary.data?.rejected.count ?? 0} projects</Badge>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={rejectedEnabled}
|
||||||
|
onCheckedChange={setRejectedEnabled}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Custom message (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={rejectedMessage}
|
||||||
|
onChange={(e) => setRejectedMessage(e.target.value)}
|
||||||
|
placeholder="Add a personal note to the rejection email..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="rejected-full-custom"
|
||||||
|
checked={rejectedFullCustom}
|
||||||
|
onCheckedChange={setRejectedFullCustom}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rejected-full-custom" className="text-xs">
|
||||||
|
Full custom body (replace default template)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="rejected-include-invite"
|
||||||
|
checked={rejectedIncludeInvite}
|
||||||
|
onCheckedChange={setRejectedIncludeInvite}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rejected-include-invite" className="text-xs">
|
||||||
|
Include platform invite link for rejected teams
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleSendRejected}
|
||||||
|
disabled={!rejectedEnabled || (summary.data?.rejected.count ?? 0) === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingRejected ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
Send Rejections
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* AWARD POOLS section */}
|
||||||
|
<Collapsible open={awardOpen} onOpenChange={setAwardOpen}>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{awardOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<Award className="h-4 w-4 text-amber-600" />
|
||||||
|
<span className="font-medium">Award Pools</span>
|
||||||
|
<Badge variant="outline">{summary.data?.awardPools.length ?? 0} awards</Badge>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
|
{(summary.data?.awardPools ?? []).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No award pools configured.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{summary.data?.awardPools.map((a) => (
|
||||||
|
<div key={a.awardId} className="flex items-center justify-between rounded border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Award className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
<span className="text-sm font-medium">{a.awardName}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">{a.eligibleCount} eligible</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAwardId(a.awardId)
|
||||||
|
handleSendAward(a.awardId)
|
||||||
|
}}
|
||||||
|
disabled={a.eligibleCount === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingAward && selectedAwardId === a.awardId ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Notify
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label className="text-xs">Custom message for awards (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={awardMessage}
|
||||||
|
onChange={(e) => setAwardMessage(e.target.value)}
|
||||||
|
placeholder="Add a note to the award notification..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Send All button */}
|
||||||
|
<div className="flex justify-end pt-2 border-t">
|
||||||
|
<Button
|
||||||
|
onClick={handleSendAll}
|
||||||
|
disabled={(!passedEnabled && !rejectedEnabled) || isSending}
|
||||||
|
>
|
||||||
|
{sendingAll ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||||
|
Send All Enabled
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</DialogContent>
|
||||||
</DialogContent>
|
</Dialog>
|
||||||
</Dialog>
|
|
||||||
|
{/* Email Preview Dialog */}
|
||||||
|
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Email Preview</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Preview for: {previewRoundName ?? 'Selected round'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{preview.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : preview.error ? (
|
||||||
|
<div className="text-destructive text-sm py-4">
|
||||||
|
Failed to load preview: {preview.error.message}
|
||||||
|
</div>
|
||||||
|
) : preview.data ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">Subject:</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{preview.data.subject}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border bg-white">
|
||||||
|
<iframe
|
||||||
|
srcDoc={preview.data.html}
|
||||||
|
className="w-full border-0 rounded"
|
||||||
|
style={{ minHeight: 500 }}
|
||||||
|
title="Email preview"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
|
|||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
...authConfig,
|
...authConfig,
|
||||||
|
logger: {
|
||||||
|
error(error) {
|
||||||
|
// CredentialsSignin is expected (wrong password, bots) — already logged to AuditLog with detail
|
||||||
|
if (error?.name === 'CredentialsSignin') return
|
||||||
|
console.error('[auth][error]', error)
|
||||||
|
},
|
||||||
|
warn(code) {
|
||||||
|
console.warn('[auth][warn]', code)
|
||||||
|
},
|
||||||
|
},
|
||||||
adapter: {
|
adapter: {
|
||||||
...PrismaAdapter(prisma),
|
...PrismaAdapter(prisma),
|
||||||
async useVerificationToken({ identifier, token }: { identifier: string; token: string }) {
|
async useVerificationToken({ identifier, token }: { identifier: string; token: string }) {
|
||||||
|
|||||||
@@ -111,9 +111,18 @@ export const fileRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900,
|
let url: string
|
||||||
input.forDownload ? { downloadFileName: input.fileName || input.objectKey.split('/').pop() || 'download' } : undefined
|
try {
|
||||||
) // 15 min
|
url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900,
|
||||||
|
input.forDownload ? { downloadFileName: input.fileName || input.objectKey.split('/').pop() || 'download' } : undefined
|
||||||
|
) // 15 min
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[file] getPresignedUrl failed:', input.objectKey, err instanceof Error ? err.message : err)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: `File not available in storage: ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Log file access
|
// Log file access
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -1670,20 +1670,63 @@ export const projectRouter = router({
|
|||||||
* Groups by round, determines next round, sends via batch sender.
|
* Groups by round, determines next round, sends via batch sender.
|
||||||
* Skips projects that have already been notified (unless skipAlreadySent=false).
|
* Skips projects that have already been notified (unless skipAlreadySent=false).
|
||||||
*/
|
*/
|
||||||
|
previewAdvancementEmail: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
customMessage: z.string().optional(),
|
||||||
|
fullCustomBody: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
competitionId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!round) throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||||||
|
|
||||||
|
const rounds = await ctx.prisma.round.findMany({
|
||||||
|
where: { competitionId: round.competitionId },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
const idx = rounds.findIndex((r) => r.id === input.roundId)
|
||||||
|
const nextRound = rounds[idx + 1]
|
||||||
|
|
||||||
|
const { getAdvancementNotificationTemplate } = await import('@/lib/email')
|
||||||
|
const template = getAdvancementNotificationTemplate(
|
||||||
|
'Team Lead Name',
|
||||||
|
'Example Project Title',
|
||||||
|
round.name,
|
||||||
|
nextRound?.name ?? 'Next Round',
|
||||||
|
input.customMessage || undefined,
|
||||||
|
undefined,
|
||||||
|
input.fullCustomBody,
|
||||||
|
)
|
||||||
|
return { subject: template.subject, html: template.html }
|
||||||
|
}),
|
||||||
|
|
||||||
sendBulkPassedNotifications: adminProcedure
|
sendBulkPassedNotifications: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
customMessage: z.string().optional(),
|
customMessage: z.string().optional(),
|
||||||
fullCustomBody: z.boolean().default(false),
|
fullCustomBody: z.boolean().default(false),
|
||||||
skipAlreadySent: z.boolean().default(true),
|
skipAlreadySent: z.boolean().default(true),
|
||||||
|
roundIds: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { customMessage, fullCustomBody, skipAlreadySent } = input
|
const { customMessage, fullCustomBody, skipAlreadySent, roundIds } = input
|
||||||
|
|
||||||
// Find all PASSED project round states
|
// Find all PASSED project round states (optionally filtered by round)
|
||||||
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
where: { state: 'PASSED' },
|
where: {
|
||||||
|
state: 'PASSED',
|
||||||
|
...(roundIds && roundIds.length > 0 ? { roundId: { in: roundIds } } : {}),
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
projectId: true,
|
projectId: true,
|
||||||
roundId: true,
|
roundId: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user