-
Notifications
You must be signed in to change notification settings - Fork 1
feat(#946): passport scan-image upload (single-slot on contacts.*, preserves #906) #948
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| 'use client' | ||
|
|
||
| /** | ||
| * #946: Passport SCAN IMAGE manager (admin contact record). | ||
| * | ||
| * Single-slot asset attached to the passport field-data on contacts.* (NOT a | ||
| * contact_documents row — preserves the #906 invariant). View (signed URL), | ||
| * upload, replace, delete. Camera capture (capture=environment) for tablets. | ||
| */ | ||
|
|
||
| import { useRef, useState } from 'react' | ||
| import { Loader2, Upload, Camera, Trash2, FileText, ExternalLink } from 'lucide-react' | ||
| import { | ||
| useContactPassportImage, | ||
| useUploadContactPassportImage, | ||
| useDeleteContactPassportImage, | ||
| } from '@/hooks/use-contacts' | ||
| import { Button } from '@/components/ui/button' | ||
| import { useToast } from '@/hooks/use-toast' | ||
| import { ApiError } from '@/lib/api' | ||
| import { formatDateOnly } from '@/lib/date-utils' | ||
|
|
||
| const MAX_MB = 10 | ||
|
|
||
| export function PassportScan({ contactId }: { contactId: string }) { | ||
| const { data, isLoading } = useContactPassportImage(contactId) | ||
| const uploadMutation = useUploadContactPassportImage() | ||
| const deleteMutation = useDeleteContactPassportImage() | ||
| const { toast } = useToast() | ||
| const fileInputRef = useRef<HTMLInputElement>(null) | ||
| const cameraInputRef = useRef<HTMLInputElement>(null) | ||
| const [error, setError] = useState('') | ||
|
|
||
| const pending = uploadMutation.isPending || deleteMutation.isPending | ||
|
|
||
| async function handleFile(e: React.ChangeEvent<HTMLInputElement>) { | ||
| const file = e.target.files?.[0] | ||
| e.target.value = '' // allow re-selecting the same file | ||
| if (!file) return | ||
| setError('') | ||
| if (file.size > MAX_MB * 1024 * 1024) { | ||
| setError(`File must be under ${MAX_MB}MB`) | ||
| return | ||
| } | ||
| try { | ||
| await uploadMutation.mutateAsync({ contactId, file }) | ||
| toast({ title: 'Passport scan saved' }) | ||
| } catch (err) { | ||
| if (err instanceof ApiError) { | ||
| toast({ title: 'Upload failed', description: err.message, variant: 'destructive' }) | ||
| return | ||
| } | ||
| throw err | ||
| } | ||
|
Comment on lines
+48
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid rethrowing unknown errors from async event handlers. At Line 49-53 and Line 63-67, rethrowing non- Proposed fix } catch (err) {
if (err instanceof ApiError) {
toast({ title: 'Upload failed', description: err.message, variant: 'destructive' })
return
}
- throw err
+ toast({
+ title: 'Upload failed',
+ description: 'Please try again.',
+ variant: 'destructive',
+ })
+ return
}
}
@@
} catch (err) {
if (err instanceof ApiError) {
toast({ title: 'Delete failed', description: err.message, variant: 'destructive' })
return
}
- throw err
+ toast({
+ title: 'Delete failed',
+ description: 'Please try again.',
+ variant: 'destructive',
+ })
+ return
}
}Also applies to: 62-68 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| async function handleDelete() { | ||
| setError('') | ||
| try { | ||
| await deleteMutation.mutateAsync(contactId) | ||
| toast({ title: 'Passport scan removed' }) | ||
| } catch (err) { | ||
| if (err instanceof ApiError) { | ||
| toast({ title: 'Delete failed', description: err.message, variant: 'destructive' }) | ||
| return | ||
| } | ||
| throw err | ||
| } | ||
| } | ||
|
|
||
| const url = data?.url ?? null | ||
|
|
||
| return ( | ||
| <div className="border-t border-ash-100 pt-3 mt-1"> | ||
| <p className="text-xs font-medium text-ash-600 mb-2">Passport Scan</p> | ||
|
|
||
| <input ref={fileInputRef} type="file" accept="image/*,application/pdf" onChange={handleFile} className="hidden" /> | ||
| {/* capture=environment opens the rear camera on mobile/tablet; ignored on desktop */} | ||
| <input ref={cameraInputRef} type="file" accept="image/*" capture="environment" onChange={handleFile} className="hidden" /> | ||
|
|
||
| {isLoading ? ( | ||
| <p className="text-sm text-ash-400">Loading…</p> | ||
| ) : url ? ( | ||
| <div className="space-y-2"> | ||
| <a | ||
| href={url} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="flex items-center gap-2 rounded-lg border border-ash-200 p-2 hover:bg-ash-50" | ||
| > | ||
| <FileText className="h-5 w-5 text-phoenix-gold-500 shrink-0" /> | ||
| <span className="flex-1 text-sm text-ash-900">View passport scan</span> | ||
| <ExternalLink className="h-3.5 w-3.5 text-ash-400" /> | ||
| </a> | ||
| {data?.uploadedAt && ( | ||
| <p className="text-xs text-ash-500">Added {formatDateOnly(data.uploadedAt)}</p> | ||
| )} | ||
| <div className="flex gap-2"> | ||
| <Button size="sm" variant="ghost" className="h-7 px-2" disabled={pending} onClick={() => fileInputRef.current?.click()}> | ||
| {uploadMutation.isPending ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1" />} | ||
| Replace | ||
| </Button> | ||
| <Button size="sm" variant="ghost" className="h-7 px-2 text-red-600 hover:text-red-700" disabled={pending} onClick={handleDelete}> | ||
| <Trash2 className="h-3.5 w-3.5 mr-1" /> Delete | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ) : ( | ||
| <div className="flex gap-2"> | ||
| <Button size="sm" variant="outline" className="h-8" disabled={pending} onClick={() => fileInputRef.current?.click()}> | ||
| {uploadMutation.isPending ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1" />} | ||
| Upload scan | ||
| </Button> | ||
| <Button size="sm" variant="outline" className="h-8" disabled={pending} onClick={() => cameraInputRef.current?.click()}> | ||
| <Camera className="h-3.5 w-3.5 mr-1" /> Take Photo | ||
| </Button> | ||
| </div> | ||
| )} | ||
|
|
||
| {error && <p className="text-xs text-red-600 mt-1">{error}</p>} | ||
| </div> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate MIME type client-side against the backend allowlist before upload.
Line 77-79 currently allows broad
image/*, while the API accepts only PDF/JPEG/PNG/GIF/WebP. Files like HEIC/SVG become guaranteed server-side failures; pre-validating at Line 41-44 gives immediate, clearer feedback.Proposed fix
Also applies to: 77-79
🤖 Prompt for AI Agents