'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslations } from 'next-intl' import { AlertTriangle, ExternalLink, FileText, ImageIcon, Loader2, Lock, Paperclip, RefreshCw, Trash2, } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { Skeleton } from '@/components/ui/skeleton' interface DocumentRecord { id: string file_name: string file_size_bytes: number mime_type: string | null storage_path: string created_at: string download_url?: string } interface AttachmentPreviewSheetProps { entryId: string | null open: boolean onOpenChange: (open: boolean) => void } // Tri-state integrity result so a transport/parse failure does not get // collapsed into "valid". Document bytes are immutable (WORM), so once we // have a definitive valid/invalid we can also memoise across re-opens of // the sheet — the integrity probe is the most expensive call on this // surface and there's no need to re-run it for the same document twice in // a session. type IntegrityState = 'valid' | 'invalid' | 'error' const integrityCache = new Map() function isImageType(type: string | null, fileName?: string): boolean { if (type?.startsWith('image/')) return true // Legacy uploads and browsers that fail to sniff sometimes leave mime_type // null or set it to application/octet-stream — fall back to filename. if (type === null || type === 'application/octet-stream') { return /\.(jpe?g|png|gif|webp|svg)$/i.test(fileName ?? '') } return false } function isPdfType(type: string | null, fileName?: string): boolean { if (type === 'application/pdf') return true if (type === null || type === 'application/octet-stream') { return fileName?.toLowerCase().endsWith('.pdf') ?? false } return false } function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } export default function AttachmentPreviewSheet({ entryId, open, onOpenChange, }: AttachmentPreviewSheetProps) { const t = useTranslations('attachment_preview_sheet') const tj = useTranslations('journal_attachments') const { toast } = useToast() const [documents, setDocuments] = useState([]) const [loading, setLoading] = useState(false) const [integrity, setIntegrity] = useState>({}) const [blockedDoc, setBlockedDoc] = useState(null) const [replacingDocId, setReplacingDocId] = useState(null) const replaceFileInputRef = useRef(null) const replaceTargetIdRef = useRef(null) const fetchAttachments = useCallback(async (id: string) => { setLoading(true) try { const res = await fetch( `/api/documents?journal_entry_id=${id}¤t_only=true` ) const { data } = await res.json() const list: DocumentRecord[] = data || [] // Pull a signed download_url for each — used by the // "open in new tab" link. The iframe/img sources use the // same-origin inline proxy and do not need download_url. const enriched = await Promise.all( list.map(async (doc) => { try { const r = await fetch(`/api/documents/${doc.id}`) const { data: detail } = await r.json() return detail?.download_url ? { ...doc, download_url: detail.download_url as string } : doc } catch { return doc } }) ) setDocuments(enriched) // Probe storage bytes for PDFs that we haven't already classified in // this session. Legacy MCP uploads from before magic-byte validation // can have non-PDF bytes stored under mime_type='application/pdf'; // Chrome's PDF viewer surfaces this as "Failed to load PDF document" // only after the user has tried to view the file, so we want a // clearer warning up front. // // Why per-session caching: document bytes are immutable (WORM) once // uploaded, so a definitive valid/invalid result never changes for // the same document id. Re-running the probe every time the sheet // opens would be wasted bandwidth and unnecessary processing of // financial documents (GDPR Art. 5(1)(b) data minimisation). const seeded: Record = {} const needsProbe: DocumentRecord[] = [] for (const doc of enriched) { if (doc.mime_type !== 'application/pdf') continue const cached = integrityCache.get(doc.id) if (cached) { seeded[doc.id] = cached } else { needsProbe.push(doc) } } if (Object.keys(seeded).length > 0) { setIntegrity(seeded) } const results = await Promise.all( needsProbe.map(async (doc) => { try { const r = await fetch(`/api/documents/${doc.id}/integrity`) if (!r.ok) { // Server reachable but returned a non-2xx — treat as unknown. // Caching the error would stick across reloads of the sheet, // which is not what we want for transient 5xx. return [doc.id, 'error' as const, false] as const } const { data } = await r.json() const state: IntegrityState = data?.valid === false ? 'invalid' : 'valid' return [doc.id, state, true] as const } catch { return [doc.id, 'error' as const, false] as const } }) ) const next: Record = { ...seeded } for (const [docId, state, cache] of results) { next[docId] = state if (cache) integrityCache.set(docId, state) } setIntegrity(next) } catch { setDocuments([]) setIntegrity({}) } finally { setLoading(false) } }, []) useEffect(() => { if (open && entryId) { fetchAttachments(entryId) } else if (!open) { setDocuments([]) setIntegrity({}) setBlockedDoc(null) } }, [open, entryId, fetchAttachments]) const handleOpenReplacePicker = (docId: string) => { replaceTargetIdRef.current = docId replaceFileInputRef.current?.click() } const handleReplaceFileSelected = async (file: File | null) => { const docId = replaceTargetIdRef.current replaceTargetIdRef.current = null if (replaceFileInputRef.current) { replaceFileInputRef.current.value = '' } if (!file || !docId || !entryId) return setReplacingDocId(docId) try { const fd = new FormData() fd.append('file', file) const res = await fetch(`/api/documents/${docId}/versions`, { method: 'POST', body: fd, }) if (!res.ok) { const { error } = await res.json().catch(() => ({ error: undefined })) toast({ title: tj('replace_failed'), description: error || undefined, variant: 'destructive', }) } else { await fetchAttachments(entryId) setBlockedDoc(null) } } catch { toast({ title: tj('replace_failed'), variant: 'destructive' }) } finally { setReplacingDocId(null) } } return ( {t('title')} handleReplaceFileSelected(e.target.files?.[0] ?? null)} /> {loading ? (
) : documents.length === 0 ? (

{t('empty')}

) : (
{documents.map((doc) => { const inlineSrc = `/api/documents/${doc.id}/inline` const previewable = isImageType(doc.mime_type, doc.file_name) || isPdfType(doc.mime_type, doc.file_name) const isReplacing = replacingDocId === doc.id return (
{isImageType(doc.mime_type, doc.file_name) ? ( ) : ( )}

{doc.file_name}

{formatFileSize(doc.file_size_bytes)}

{doc.download_url && ( {t('open_in_new_tab')} )}
{isPdfType(doc.mime_type, doc.file_name) && integrity[doc.id] === 'invalid' && (

{t('corrupt_title')}

{t('corrupt_body')}

)} {isPdfType(doc.mime_type, doc.file_name) && integrity[doc.id] !== 'invalid' && ( // + type="application/pdf" invokes Chrome's PDF // plugin directly.