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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
FileUp,
|
FileUp,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn, formatFileSize } from '@/lib/utils'
|
import { cn, formatFileSize } from '@/lib/utils'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
|
@ -77,12 +78,13 @@ export default function BulkUploadPage() {
|
||||||
label: string
|
label: string
|
||||||
mimeTypes: string[]
|
mimeTypes: string[]
|
||||||
required: boolean
|
required: boolean
|
||||||
file: { id: string; fileName: string } | null
|
file: { id: string; fileName: string; bucket: string; objectKey: string } | null
|
||||||
}>
|
}>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [bulkFiles, setBulkFiles] = useState<Record<string, File | null>>({})
|
const [bulkFiles, setBulkFiles] = useState<Record<string, File | null>>({})
|
||||||
|
|
||||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
@ -109,6 +111,50 @@ export default function BulkUploadPage() {
|
||||||
{ enabled: !!roundId }
|
{ 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()
|
const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
|
||||||
|
|
||||||
// Upload a single file for a project requirement
|
// Upload a single file for a project requirement
|
||||||
|
|
@ -390,7 +436,7 @@ export default function BulkUploadPage() {
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.projects.map((row) => {
|
{data.projects.map((row) => {
|
||||||
const missingRequired = row.requirements.filter(
|
const missingRequired = row.requirements.filter(
|
||||||
(r) => r.required && !r.file
|
(r) => r.required && (!r.file || (r.file?.objectKey && missingFiles.has(r.file.objectKey)))
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
|
|
@ -446,12 +492,44 @@ export default function BulkUploadPage() {
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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' ? (
|
) : req.file || uploadState?.status === 'complete' ? (
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
|
{req.file?.bucket && req.file?.objectKey ? (
|
||||||
{req.file?.fileName ?? 'Uploaded'}
|
<button
|
||||||
</span>
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1295,6 +1295,8 @@ export const fileRouter = router({
|
||||||
size: true,
|
size: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
requirementId: true,
|
requirementId: true,
|
||||||
|
bucket: true,
|
||||||
|
objectKey: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1487,4 +1489,37 @@ export const fileRouter = router({
|
||||||
|
|
||||||
return { uploadUrl, file }
|
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