feat: per-round advancement selection, email preview, Docker/auth fixes
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:
2026-03-04 14:31:01 +01:00
parent 267d26581d
commit af03c12ae5
5 changed files with 410 additions and 228 deletions

View File

@@ -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

View File

@@ -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">&rarr; {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>
</>
)
}

View File

@@ -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 }) {

View File

@@ -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({

View File

@@ -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,