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
|
||||
networks:
|
||||
- mopc-network
|
||||
- minio-external
|
||||
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))"]
|
||||
interval: 30s
|
||||
@@ -82,3 +83,6 @@ volumes:
|
||||
networks:
|
||||
mopc-network:
|
||||
driver: bridge
|
||||
minio-external:
|
||||
external: true
|
||||
name: minio_mopc-minio
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ 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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
Trophy,
|
||||
Ban,
|
||||
Award,
|
||||
Eye,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface BulkNotificationDialogProps {
|
||||
@@ -47,6 +50,11 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
||||
const [passedEnabled, setPassedEnabled] = useState(true)
|
||||
const [passedMessage, setPassedMessage] = useState('')
|
||||
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
|
||||
const [rejectedEnabled, setRejectedEnabled] = useState(false)
|
||||
@@ -71,17 +79,49 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
||||
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 sendRejected = trpc.project.sendBulkRejectionNotifications.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 () => {
|
||||
if (selectedRoundIds.size === 0) {
|
||||
toast.error('Select at least one round to notify')
|
||||
return
|
||||
}
|
||||
setSendingPassed(true)
|
||||
try {
|
||||
const result = await sendPassed.mutateAsync({
|
||||
customMessage: passedMessage || undefined,
|
||||
fullCustomBody: passedFullCustom,
|
||||
skipAlreadySent,
|
||||
roundIds: Array.from(selectedRoundIds),
|
||||
})
|
||||
toast.success(`Advancement: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
||||
summary.refetch()
|
||||
@@ -130,7 +170,7 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
||||
const handleSendAll = async () => {
|
||||
setSendingAll(true)
|
||||
try {
|
||||
if (passedEnabled && totalPassed > 0) {
|
||||
if (passedEnabled && selectedPassedCount > 0) {
|
||||
await handleSendPassed()
|
||||
}
|
||||
if (rejectedEnabled && (summary.data?.rejected.count ?? 0) > 0) {
|
||||
@@ -144,10 +184,24 @@ 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
|
||||
|
||||
// Find round name for preview
|
||||
const previewRoundName = summary.data?.passed.find((g) => g.roundId === previewRoundId)?.roundName
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
@@ -192,7 +246,11 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
||||
{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>
|
||||
<Badge variant="secondary">
|
||||
{selectedRoundIds.size > 0
|
||||
? `${selectedPassedCount} of ${totalPassed} selected`
|
||||
: `${totalPassed} projects`}
|
||||
</Badge>
|
||||
</div>
|
||||
<Switch
|
||||
checked={passedEnabled}
|
||||
@@ -203,13 +261,22 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
||||
<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" />
|
||||
<label
|
||||
key={g.roundId}
|
||||
className="text-sm flex items-center gap-2 cursor-pointer hover:bg-muted/30 rounded px-1 py-0.5 -mx-1"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRoundIds.has(g.roundId)}
|
||||
onCheckedChange={() => toggleRound(g.roundId)}
|
||||
/>
|
||||
<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>
|
||||
<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
|
||||
@@ -230,15 +297,26 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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 || totalPassed === 0 || isSending}
|
||||
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
|
||||
Send Advancement ({selectedPassedCount})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
@@ -378,5 +456,43 @@ export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationD
|
||||
)}
|
||||
</DialogContent>
|
||||
</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({
|
||||
...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: {
|
||||
...PrismaAdapter(prisma),
|
||||
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
|
||||
try {
|
||||
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
|
||||
await logAudit({
|
||||
|
||||
@@ -1670,20 +1670,63 @@ export const projectRouter = router({
|
||||
* Groups by round, determines next round, sends via batch sender.
|
||||
* 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
|
||||
.input(
|
||||
z.object({
|
||||
customMessage: z.string().optional(),
|
||||
fullCustomBody: z.boolean().default(false),
|
||||
skipAlreadySent: z.boolean().default(true),
|
||||
roundIds: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
.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({
|
||||
where: { state: 'PASSED' },
|
||||
where: {
|
||||
state: 'PASSED',
|
||||
...(roundIds && roundIds.length > 0 ? { roundId: { in: roundIds } } : {}),
|
||||
},
|
||||
select: {
|
||||
projectId: true,
|
||||
roundId: true,
|
||||
|
||||
Reference in New Issue
Block a user