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:
parent
c707899179
commit
85a0fa5016
|
|
@ -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" />
|
||||
{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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue