Make bulk upload documents clickable with storage verification

- Add bucket/objectKey to file select in listProjectsByRoundRequirements
- Add verifyFilesExist endpoint to bulk-check file existence in MinIO
- Make uploaded filenames clickable links that open presigned download URLs
- Verify files exist in storage on page load, show re-upload button if missing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-16 13:32:23 +01:00
parent c707899179
commit 85a0fa5016
2 changed files with 119 additions and 6 deletions

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
@ -46,6 +46,7 @@ import {
Loader2,
FileUp,
AlertCircle,
ExternalLink,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { Pagination } from '@/components/shared/pagination'
@ -77,12 +78,13 @@ export default function BulkUploadPage() {
label: string
mimeTypes: string[]
required: boolean
file: { id: string; fileName: string } | null
file: { id: string; fileName: string; bucket: string; objectKey: string } | null
}>
} | null>(null)
const [bulkFiles, setBulkFiles] = useState<Record<string, File | null>>({})
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
const utils = trpc.useUtils()
// Debounce search
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
@ -109,6 +111,50 @@ export default function BulkUploadPage() {
{ enabled: !!roundId }
)
// Collect all files from current data for existence verification
const filesToVerify = useMemo(() => {
if (!data?.projects) return []
const files: { bucket: string; objectKey: string }[] = []
for (const row of data.projects) {
for (const req of row.requirements) {
if (req.file?.bucket && req.file?.objectKey) {
files.push({ bucket: req.file.bucket, objectKey: req.file.objectKey })
}
}
}
return files
}, [data])
// Verify files actually exist in storage
const { data: fileExistence } = trpc.file.verifyFilesExist.useQuery(
{ files: filesToVerify },
{ enabled: filesToVerify.length > 0, staleTime: 30_000 }
)
// Track which files are missing from storage (objectKey → true means missing)
const missingFiles = useMemo(() => {
if (!fileExistence) return new Set<string>()
const missing = new Set<string>()
for (const [key, exists] of Object.entries(fileExistence)) {
if (!exists) missing.add(key)
}
return missing
}, [fileExistence])
// Open file in new tab via presigned URL
const handleViewFile = useCallback(
async (bucket: string, objectKey: string) => {
try {
const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey })
window.open(url, '_blank')
} catch {
toast.error('Failed to open file. It may have been deleted from storage.')
refetch()
}
},
[utils, refetch]
)
const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
// Upload a single file for a project requirement
@ -390,7 +436,7 @@ export default function BulkUploadPage() {
<TableBody>
{data.projects.map((row) => {
const missingRequired = row.requirements.filter(
(r) => r.required && !r.file
(r) => r.required && (!r.file || (r.file?.objectKey && missingFiles.has(r.file.objectKey)))
)
return (
<TableRow
@ -446,12 +492,44 @@ export default function BulkUploadPage() {
Retry
</Button>
</div>
) : req.file && req.file.objectKey && missingFiles.has(req.file.objectKey) ? (
<div className="flex flex-col items-center gap-1">
<AlertCircle className="h-4 w-4 text-amber-500" />
<span className="text-[10px] text-amber-600 font-medium">Missing</span>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() =>
handleCellUpload(
row.project.id,
req.requirementId,
req.mimeTypes
)
}
>
Re-upload
</Button>
</div>
) : req.file || uploadState?.status === 'complete' ? (
<div className="flex flex-col items-center gap-1">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
{req.file?.fileName ?? 'Uploaded'}
</span>
{req.file?.bucket && req.file?.objectKey ? (
<button
type="button"
className="text-[10px] text-teal-600 hover:text-teal-800 hover:underline truncate max-w-[120px] flex items-center gap-0.5 cursor-pointer"
onClick={() =>
handleViewFile(req.file!.bucket, req.file!.objectKey)
}
>
{req.file.fileName}
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
</button>
) : (
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
{req.file?.fileName ?? 'Uploaded'}
</span>
)}
</div>
) : (
<Button

View File

@ -1295,6 +1295,8 @@ export const fileRouter = router({
size: true,
createdAt: true,
requirementId: true,
bucket: true,
objectKey: true,
},
},
},
@ -1487,4 +1489,37 @@ export const fileRouter = router({
return { uploadUrl, file }
}),
/**
* Verify that files actually exist in storage (MinIO/S3).
* Returns a map of objectKey exists boolean.
*/
verifyFilesExist: adminProcedure
.input(
z.object({
files: z.array(
z.object({
bucket: z.string(),
objectKey: z.string(),
})
).max(200),
})
)
.query(async ({ input }) => {
const { getMinioClient } = await import('@/lib/minio')
const client = getMinioClient()
const results: Record<string, boolean> = {}
await Promise.all(
input.files.map(async ({ bucket, objectKey }) => {
try {
await client.statObject(bucket, objectKey)
results[objectKey] = true
} catch {
results[objectKey] = false
}
})
)
return results
}),
})