Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions apps/admin/src/app/contacts/[id]/_components/passport-scan.tsx
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
}
Comment on lines +41 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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
 const MAX_MB = 10
+const ALLOWED_MIME_TYPES = new Set([
+  'application/pdf',
+  'image/jpeg',
+  'image/png',
+  'image/gif',
+  'image/webp',
+])

@@
     if (file.size > MAX_MB * 1024 * 1024) {
       setError(`File must be under ${MAX_MB}MB`)
       return
     }
+    if (!ALLOWED_MIME_TYPES.has(file.type)) {
+      setError('Invalid file type. Allowed: PDF, JPEG, PNG, GIF, WebP')
+      return
+    }

@@
-      <input ref={fileInputRef} type="file" accept="image/*,application/pdf" onChange={handleFile} className="hidden" />
+      <input ref={fileInputRef} type="file" accept="application/pdf,image/jpeg,image/png,image/gif,image/webp" onChange={handleFile} className="hidden" />
@@
-      <input ref={cameraInputRef} type="file" accept="image/*" capture="environment" onChange={handleFile} className="hidden" />
+      <input ref={cameraInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" capture="environment" onChange={handleFile} className="hidden" />

Also applies to: 77-79

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/admin/src/app/contacts/`[id]/_components/passport-scan.tsx around lines
41 - 44, The file size validation in the passport-scan.tsx component at lines
41-44 needs to be enhanced with MIME type validation that matches the backend
allowlist. Currently, the MIME type check at lines 77-79 allows all image types
with `image/*`, but the API only accepts PDF, JPEG, PNG, GIF, and WebP. Add a
validation check that verifies the file's MIME type against this specific
allowlist before upload, placing it alongside the size check so users receive
immediate feedback on invalid formats rather than guaranteed server-side
failures. This validation should reject files like HEIC and SVG at the
client-side with a clear error message.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid rethrowing unknown errors from async event handlers.

At Line 49-53 and Line 63-67, rethrowing non-ApiError values from handleFile/handleDelete can surface as unhandled promise rejections in the UI path. Prefer showing a generic destructive toast and returning.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/admin/src/app/contacts/`[id]/_components/passport-scan.tsx around lines
48 - 54, In both the handleFile and handleDelete functions, replace the rethrow
of non-ApiError exceptions with a fallback toast notification. Instead of
throwing unknown errors, catch all errors that are not ApiError instances,
display a generic destructive toast with a message like "An error occurred", and
return gracefully to prevent unhandled promise rejections in the UI. Apply this
same pattern to both the error handler shown in the diff and the second
occurrence in the handleDelete function.

}

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>
)
}
3 changes: 3 additions & 0 deletions apps/admin/src/app/contacts/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { RelationshipsSection } from './_components/relationships-section'
import { LoyaltyProgramsSection } from './_components/loyalty-programs-section'
import { LoyaltyProgramDialog } from './_components/loyalty-program-dialog'
import { ContactDocumentsSection } from './_components/contact-documents-section'
import { PassportScan } from './_components/passport-scan'
import { ContactEmailsSection } from './_components/contact-emails-section'
import { RecentEmailsCard } from './_components/recent-emails-card'
import { NotesSection } from '@/components/notes/NotesSection'
Expand Down Expand Up @@ -1320,6 +1321,8 @@ export default function ContactDetailPage() {
) : (
<p className="text-sm text-ash-500">No passport on file. Click <span className="font-medium">Add</span> to enter passport details.</p>
)}
{/* #946: passport scan image (single-slot, on contacts.* — no doc row) */}
<PassportScan contactId={contact.id} />
</CardContent>
</Card>

Expand Down
46 changes: 46 additions & 0 deletions apps/admin/src/hooks/use-contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,49 @@ export function useUpdateMarketingConsent() {
},
})
}

// ===========================================================================
// #946 — Passport SCAN IMAGE (admin). Single-slot asset on contacts.* (signed
// URL view, null-safe). NOT a contact_documents row (preserves #906 invariant).
// ===========================================================================

export interface PassportImageResult {
url: string | null
uploadedAt: string | null
}

const passportImageKey = (contactId: string) =>
[...contactKeys.detail(contactId), 'passport-image'] as const

export function useContactPassportImage(contactId: string | null) {
return useQuery({
queryKey: passportImageKey(contactId || ''),
queryFn: () => api.get<PassportImageResult>(`/contacts/${contactId}/passport-image`),
enabled: !!contactId,
})
}

export function useUploadContactPassportImage() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ contactId, file }: { contactId: string; file: File }) => {
const formData = new FormData()
formData.append('file', file)
return api.postFormData<PassportImageResult>(`/contacts/${contactId}/passport-image`, formData)
},
onSuccess: (_, { contactId }) => {
queryClient.invalidateQueries({ queryKey: passportImageKey(contactId) })
},
})
}

export function useDeleteContactPassportImage() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (contactId: string) =>
api.delete<{ deleted: boolean }>(`/contacts/${contactId}/passport-image`),
onSuccess: (_, contactId) => {
queryClient.invalidateQueries({ queryKey: passportImageKey(contactId) })
},
})
}
119 changes: 117 additions & 2 deletions apps/api/src/contacts/contact-documents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
* Works with the contact_documents table.
*/

import { Injectable } from '@nestjs/common'
import { eq } from 'drizzle-orm'
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { and, eq, isNull } from 'drizzle-orm'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { DatabaseService } from '../db/database.service'
import { StorageService } from '../trips/storage.service'
import { AuditEvent } from '../activity-logs/events/audit.event'
import { sanitizeForAudit, computeAuditDiff } from '../activity-logs/audit-sanitizer'

Expand All @@ -31,13 +32,127 @@ export interface ContactDocumentDto {
uploadedBy: string | null
}

/** #946: passport scan-image result shape (signed URL view; null when none). */
export interface PassportImageDto {
url: string | null
uploadedAt: string | null
}

@Injectable()
export class ContactDocumentsService {
private readonly logger = new Logger(ContactDocumentsService.name)

constructor(
private readonly db: DatabaseService,
private readonly eventEmitter: EventEmitter2,
private readonly storageService: StorageService,
) {}

// ===========================================================================
// #946 — Passport SCAN IMAGE (single-slot asset on contacts.*, NOT a
// contact_documents row — preserves the #906 invariant). Stores the R2 key;
// signs on read. Shared by the admin endpoint and (mirrored) the portal one.
// ===========================================================================

/** Upload/replace the contact's passport scan. Returns a signed view URL. */
async setPassportImage(
contactId: string,
file: { buffer: Buffer; originalName: string; mimeType: string },
): Promise<PassportImageDto> {
const [contact] = await this.db.client
.select({ id: this.db.schema.contacts.id, passportImageKey: this.db.schema.contacts.passportImageKey })
.from(this.db.schema.contacts)
.where(eq(this.db.schema.contacts.id, contactId))
.limit(1)
if (!contact) throw new NotFoundException(`Contact ${contactId} not found`)

const oldKey = contact.passportImageKey
// Upload NEW first (no data loss if the DB write fails).
const newKey = await this.storageService.uploadDocument(
file.buffer,
`contacts/${contactId}/passport`,
file.originalName,
file.mimeType,
)
const uploadedAt = new Date()
let updated: { id: string }[]
try {
// Compare-and-set: only claim the slot if it still holds the key we read.
// Guards against a concurrent replace silently orphaning the loser's blob.
updated = await this.db.client
.update(this.db.schema.contacts)
.set({ passportImageKey: newKey, passportImageUploadedAt: uploadedAt, updatedAt: uploadedAt })
.where(
and(
eq(this.db.schema.contacts.id, contactId),
oldKey
? eq(this.db.schema.contacts.passportImageKey, oldKey)
: isNull(this.db.schema.contacts.passportImageKey),
),
)
.returning({ id: this.db.schema.contacts.id })
} catch (error) {
// Roll back the orphaned upload on DB failure.
try { await this.storageService.deleteDocument(newKey) } catch (e) {
this.logger.warn(`Failed to clean up orphaned passport upload for contact ${contactId}: ${e}`)
}
throw error
}
// A concurrent replace won the slot — delete OUR new blob rather than
// orphaning the winner's, and signal the caller to retry.
if (updated.length === 0) {
try { await this.storageService.deleteDocument(newKey) } catch (e) {
this.logger.warn(`Failed to clean up superseded passport upload for contact ${contactId}: ${e}`)
}
throw new ConflictException('Passport image was updated concurrently; please retry')
}
// Best-effort delete of the replaced scan.
if (oldKey && oldKey !== newKey) {
try { await this.storageService.deleteDocument(oldKey) } catch (e) {
this.logger.warn(`Failed to delete previous passport scan for contact ${contactId}: ${e}`)
}
}
const url = await this.storageService.getSignedUrl(newKey).catch(() => null)
return { url, uploadedAt: uploadedAt.toISOString() }
}

/** Signed view URL for the contact's passport scan (null when none). */
async getPassportImage(contactId: string): Promise<PassportImageDto> {
const [contact] = await this.db.client
.select({
passportImageKey: this.db.schema.contacts.passportImageKey,
passportImageUploadedAt: this.db.schema.contacts.passportImageUploadedAt,
})
.from(this.db.schema.contacts)
.where(eq(this.db.schema.contacts.id, contactId))
.limit(1)
if (!contact) throw new NotFoundException(`Contact ${contactId} not found`)
if (!contact.passportImageKey) return { url: null, uploadedAt: null }
const url = await this.storageService.getSignedUrl(contact.passportImageKey).catch(() => null)
return { url, uploadedAt: contact.passportImageUploadedAt?.toISOString() ?? null }
}

/** Remove the contact's passport scan (DB + best-effort R2). */
async deletePassportImage(contactId: string): Promise<{ deleted: boolean }> {
const [contact] = await this.db.client
.select({ id: this.db.schema.contacts.id, passportImageKey: this.db.schema.contacts.passportImageKey })
.from(this.db.schema.contacts)
.where(eq(this.db.schema.contacts.id, contactId))
.limit(1)
if (!contact) throw new NotFoundException(`Contact ${contactId} not found`)
const key = contact.passportImageKey
await this.db.client
.update(this.db.schema.contacts)
.set({ passportImageKey: null, passportImageUploadedAt: null, updatedAt: new Date() })
.where(eq(this.db.schema.contacts.id, contactId))
if (key) {
try { await this.storageService.deleteDocument(key) } catch (e) {
this.logger.warn(`Failed to delete passport scan for contact ${contactId} from storage: ${e}`)
}
}
return { deleted: true }
}

async findByContactId(contactId: string): Promise<ContactDocumentDto[]> {
const documents = await this.db.client
.select()
Expand Down
Loading
Loading