feat: extend notification system with batch sender, bulk dialog, and logging

Add NotificationLog schema extensions (nullable userId, email, roundId,
projectId, batchId fields), batch notification sender service, and bulk
notification dialog UI. Include utility scripts for debugging and seeding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 13:29:06 +01:00
parent 8f2f054c57
commit f24bea3df2
15 changed files with 1316 additions and 16 deletions

View File

@@ -0,0 +1,382 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ChevronDown,
ChevronRight,
Send,
Loader2,
CheckCircle2,
XCircle,
Trophy,
Ban,
Award,
} from 'lucide-react'
interface BulkNotificationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationDialogProps) {
// Section states
const [passedOpen, setPassedOpen] = useState(true)
const [rejectedOpen, setRejectedOpen] = useState(false)
const [awardOpen, setAwardOpen] = useState(false)
// Passed section
const [passedEnabled, setPassedEnabled] = useState(true)
const [passedMessage, setPassedMessage] = useState('')
const [passedFullCustom, setPassedFullCustom] = useState(false)
// Rejected section
const [rejectedEnabled, setRejectedEnabled] = useState(false)
const [rejectedMessage, setRejectedMessage] = useState('')
const [rejectedFullCustom, setRejectedFullCustom] = useState(false)
const [rejectedIncludeInvite, setRejectedIncludeInvite] = useState(false)
// Award section
const [selectedAwardId, setSelectedAwardId] = useState<string | null>(null)
const [awardMessage, setAwardMessage] = useState('')
// Global
const [skipAlreadySent, setSkipAlreadySent] = useState(true)
// Loading states
const [sendingPassed, setSendingPassed] = useState(false)
const [sendingRejected, setSendingRejected] = useState(false)
const [sendingAward, setSendingAward] = useState(false)
const [sendingAll, setSendingAll] = useState(false)
const summary = trpc.project.getBulkNotificationSummary.useQuery(undefined, {
enabled: open,
})
const sendPassed = trpc.project.sendBulkPassedNotifications.useMutation()
const sendRejected = trpc.project.sendBulkRejectionNotifications.useMutation()
const sendAward = trpc.project.sendBulkAwardNotifications.useMutation()
const handleSendPassed = async () => {
setSendingPassed(true)
try {
const result = await sendPassed.mutateAsync({
customMessage: passedMessage || undefined,
fullCustomBody: passedFullCustom,
skipAlreadySent,
})
toast.success(`Advancement: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
summary.refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send')
} finally {
setSendingPassed(false)
}
}
const handleSendRejected = async () => {
setSendingRejected(true)
try {
const result = await sendRejected.mutateAsync({
customMessage: rejectedMessage || undefined,
fullCustomBody: rejectedFullCustom,
includeInviteLink: rejectedIncludeInvite,
skipAlreadySent,
})
toast.success(`Rejection: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
summary.refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send')
} finally {
setSendingRejected(false)
}
}
const handleSendAward = async (awardId: string) => {
setSendingAward(true)
try {
const result = await sendAward.mutateAsync({
awardId,
customMessage: awardMessage || undefined,
skipAlreadySent,
})
toast.success(`Award: ${result.sent} sent, ${result.failed} failed`)
summary.refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send')
} finally {
setSendingAward(false)
}
}
const handleSendAll = async () => {
setSendingAll(true)
try {
if (passedEnabled && totalPassed > 0) {
await handleSendPassed()
}
if (rejectedEnabled && (summary.data?.rejected.count ?? 0) > 0) {
await handleSendRejected()
}
toast.success('All enabled notifications sent')
} catch {
// Individual handlers already toast errors
} finally {
setSendingAll(false)
}
}
const totalPassed = summary.data?.passed.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
const isSending = sendingPassed || sendingRejected || sendingAward || sendingAll
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bulk Notifications</DialogTitle>
<DialogDescription>
Send advancement, rejection, and award pool notifications to project teams.
</DialogDescription>
</DialogHeader>
{summary.isLoading ? (
<div className="flex items-center justify-center py-8">
<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>
{/* PASSED section */}
<Collapsible open={passedOpen} onOpenChange={setPassedOpen}>
<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">
{passedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Trophy className="h-4 w-4 text-green-600" />
<span className="font-medium">Passed / Advanced</span>
<Badge variant="secondary">{totalPassed} projects</Badge>
</div>
<Switch
checked={passedEnabled}
onCheckedChange={setPassedEnabled}
onClick={(e) => e.stopPropagation()}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t px-4 pb-4 pt-3 space-y-3">
{summary.data?.passed.map((g) => (
<div key={g.roundId} className="text-sm flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
<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>
</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>
</Collapsible>
{/* REJECTED section */}
<Collapsible open={rejectedOpen} onOpenChange={setRejectedOpen}>
<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">
{rejectedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Ban className="h-4 w-4 text-red-600" />
<span className="font-medium">Rejected / Filtered Out</span>
<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>
)}
</DialogContent>
</Dialog>
)
}