From af03c12ae5aca190bb42cd8954535efb63eff053 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 14:31:01 +0100 Subject: [PATCH] feat: per-round advancement selection, email preview, Docker/auth fixes - 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 --- docker/docker-compose.yml | 4 + .../projects/bulk-notification-dialog.tsx | 560 +++++++++++------- src/lib/auth.ts | 10 + src/server/routers/file.ts | 15 +- src/server/routers/project.ts | 49 +- 5 files changed, 410 insertions(+), 228 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0a90e7d..e584d1e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/src/components/admin/projects/bulk-notification-dialog.tsx b/src/components/admin/projects/bulk-notification-dialog.tsx index bc04d2e..5faaf7d 100644 --- a/src/components/admin/projects/bulk-notification-dialog.tsx +++ b/src/components/admin/projects/bulk-notification-dialog.tsx @@ -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>(new Set()) + + // Preview + const [previewOpen, setPreviewOpen] = useState(false) + const [previewRoundId, setPreviewRoundId] = useState(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,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 + // Find round name for preview + const previewRoundName = summary.data?.passed.find((g) => g.roundId === previewRoundId)?.roundName + return ( - - - - Bulk Notifications - - Send advancement, rejection, and award pool notifications to project teams. - - + <> + + + + Bulk Notifications + + Send advancement, rejection, and award pool notifications to project teams. + + - {summary.isLoading ? ( -
- -
- ) : summary.error ? ( -
- Failed to load summary: {summary.error.message} -
- ) : ( -
- {/* Global settings */} -
-
- - -
-
- {summary.data?.alreadyNotified.advancement ?? 0} advancement + {summary.data?.alreadyNotified.rejection ?? 0} rejection already sent -
+ {summary.isLoading ? ( +
+
- - {/* PASSED section */} - -
- -
- {passedOpen ? : } - - Passed / Advanced - {totalPassed} projects -
+ ) : summary.error ? ( +
+ Failed to load summary: {summary.error.message} +
+ ) : ( +
+ {/* Global settings */} +
+
e.stopPropagation()} + id="skip-already-sent" + checked={skipAlreadySent} + onCheckedChange={setSkipAlreadySent} /> - - -
- {summary.data?.passed.map((g) => ( -
- - {g.roundName} - {g.projectCount} - → {g.nextRoundName} -
- ))} -
- -