From de88109046a228bd76c03fb2cad564d8bcb3cd23 Mon Sep 17 00:00:00 2001 From: Al Date: Tue, 16 Jun 2026 16:34:55 -0400 Subject: [PATCH 1/2] feat(#946): passport scan-image upload (single-slot on contacts.*, preserves #906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let agents (admin contact record) and clients (portal) attach a passport SCAN IMAGE to a contact's passport — as an optional single-slot asset ON the passport field-data, NOT a passport-typed contact_documents row (preserves the #906 invariant; the portal still rejects documentType=passport). Data: migration adds nullable contacts.passport_image_key (R2 storage key — sign on read, mirrors contact_documents.file_url; also the stable handle #947 OCR fetches by) + passport_image_uploaded_at. Replay-clean; staging-rehearsed. Backend (reuses #906 StorageService plumbing — uploadDocument/getSignedUrl/ deleteDocument; null-safe throughout): - Admin: ContactDocumentsService.{setPassportImage,getPassportImage, deletePassportImage} + ContactPassportImageController (GET/POST/DELETE /contacts/:id/passport-image, sensitive-access guarded). - Portal: PortalService.{upload,get,delete}PortalPassportImage + GET/POST/DELETE /portal/me/passport-image (own contact). - Single slot: replace uploads-new-first then deletes the prior key; delete is null-safe; getSignedUrl never called on a null key. UI (signed-URL view, replace, delete, mobile camera capture=environment): - Admin Passport Manager card (#943): + use-contacts hooks. - Portal Passport & Travel ID (#906): + use-portal-documents hooks. Spec: contact-passport-image.service.spec — upload/sign/replace + read/delete null-guards (6/6). All apps tsc clean. fixes #946 Co-Authored-By: Claude Fable 5 --- .../[id]/_components/passport-scan.tsx | 123 ++++++++++++++++++ apps/admin/src/app/contacts/[id]/page.tsx | 3 + apps/admin/src/hooks/use-contacts.ts | 46 +++++++ .../src/contacts/contact-documents.service.ts | 98 +++++++++++++- .../contact-passport-image.controller.ts | 86 ++++++++++++ .../contact-passport-image.service.spec.ts | 111 ++++++++++++++++ apps/api/src/contacts/contacts.module.ts | 2 + apps/api/src/portal/portal.controller.ts | 42 ++++++ apps/api/src/portal/portal.service.ts | 87 +++++++++++++ .../src/app/(dashboard)/travelers/page.tsx | 3 + .../components/documents/passport-scan.tsx | 113 ++++++++++++++++ apps/client/src/hooks/use-portal-documents.ts | 42 ++++++ ...60617150000_946_contact_passport_image.sql | 13 ++ .../src/migrations/meta/_journal.json | 7 + .../database/src/schema/contacts.schema.ts | 7 + 15 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/app/contacts/[id]/_components/passport-scan.tsx create mode 100644 apps/api/src/contacts/contact-passport-image.controller.ts create mode 100644 apps/api/src/contacts/contact-passport-image.service.spec.ts create mode 100644 apps/client/src/components/documents/passport-scan.tsx create mode 100644 packages/database/src/migrations/20260617150000_946_contact_passport_image.sql diff --git a/apps/admin/src/app/contacts/[id]/_components/passport-scan.tsx b/apps/admin/src/app/contacts/[id]/_components/passport-scan.tsx new file mode 100644 index 000000000..f29cfccf8 --- /dev/null +++ b/apps/admin/src/app/contacts/[id]/_components/passport-scan.tsx @@ -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(null) + const cameraInputRef = useRef(null) + const [error, setError] = useState('') + + const pending = uploadMutation.isPending || deleteMutation.isPending + + async function handleFile(e: React.ChangeEvent) { + 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 + } + } + + 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 ( +
+

Passport Scan

+ + + {/* capture=environment opens the rear camera on mobile/tablet; ignored on desktop */} + + + {isLoading ? ( +

Loading…

+ ) : url ? ( +
+ + + View passport scan + + + {data?.uploadedAt && ( +

Added {formatDateOnly(data.uploadedAt)}

+ )} +
+ + +
+
+ ) : ( +
+ + +
+ )} + + {error &&

{error}

} +
+ ) +} diff --git a/apps/admin/src/app/contacts/[id]/page.tsx b/apps/admin/src/app/contacts/[id]/page.tsx index e56da2c47..f2a078f03 100644 --- a/apps/admin/src/app/contacts/[id]/page.tsx +++ b/apps/admin/src/app/contacts/[id]/page.tsx @@ -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' @@ -1320,6 +1321,8 @@ export default function ContactDetailPage() { ) : (

No passport on file. Click Add to enter passport details.

)} + {/* #946: passport scan image (single-slot, on contacts.* — no doc row) */} + diff --git a/apps/admin/src/hooks/use-contacts.ts b/apps/admin/src/hooks/use-contacts.ts index e2752308d..44878a74c 100644 --- a/apps/admin/src/hooks/use-contacts.ts +++ b/apps/admin/src/hooks/use-contacts.ts @@ -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(`/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(`/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) }) + }, + }) +} diff --git a/apps/api/src/contacts/contact-documents.service.ts b/apps/api/src/contacts/contact-documents.service.ts index 461d889f8..48c67912f 100644 --- a/apps/api/src/contacts/contact-documents.service.ts +++ b/apps/api/src/contacts/contact-documents.service.ts @@ -5,10 +5,11 @@ * Works with the contact_documents table. */ -import { Injectable } from '@nestjs/common' +import { Injectable, Logger, NotFoundException } from '@nestjs/common' import { eq } 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' @@ -31,13 +32,108 @@ 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 { + 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() + try { + await this.db.client + .update(this.db.schema.contacts) + .set({ passportImageKey: newKey, passportImageUploadedAt: uploadedAt, updatedAt: uploadedAt }) + .where(eq(this.db.schema.contacts.id, contactId)) + } 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 ${newKey}: ${e}`) + } + throw error + } + // 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 ${oldKey}: ${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 { + 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 ${key} from storage: ${e}`) + } + } + return { deleted: true } + } + async findByContactId(contactId: string): Promise { const documents = await this.db.client .select() diff --git a/apps/api/src/contacts/contact-passport-image.controller.ts b/apps/api/src/contacts/contact-passport-image.controller.ts new file mode 100644 index 000000000..ea46b2f92 --- /dev/null +++ b/apps/api/src/contacts/contact-passport-image.controller.ts @@ -0,0 +1,86 @@ +/** + * Contact Passport Image Controller (#946) + * + * Upload / view / replace / delete the OPTIONAL passport SCAN IMAGE attached to + * a contact's passport field-data. The scan is a single-slot asset on + * contacts.* (NOT a contact_documents row) — preserving the #906 invariant. + * + * Access control mirrors the documents controller: sensitive-data access only + * (passport scans are sensitive; basic-share users are excluded). + */ + +import { + Controller, + Get, + Post, + Delete, + Param, + UploadedFile, + UseInterceptors, + BadRequestException, + NotFoundException, + ForbiddenException, +} from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ApiTags } from '@nestjs/swagger' +import { ContactDocumentsService } from './contact-documents.service' +import { ContactAccessService } from './contact-access.service' +import { GetAuthContext } from '../auth/decorators/auth-context.decorator' +import type { AuthContext } from '../auth/auth.types' + +const MAX_FILE_SIZE = 10 * 1024 * 1024 +// A passport SCAN is an image or a PDF — narrower than the documents endpoint. +const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp'] + +@ApiTags('Contact Passport Image') +@Controller('contacts/:contactId/passport-image') +export class ContactPassportImageController { + constructor( + private readonly documentsService: ContactDocumentsService, + private readonly contactAccessService: ContactAccessService, + ) {} + + private async verifySensitiveAccess(contactId: string, auth: AuthContext): Promise { + const result = await this.contactAccessService.canAccessSensitiveData(contactId, auth) + if (!result.canAccessBasic) { + throw new NotFoundException('Contact not found') + } + if (!result.canAccessSensitive) { + throw new ForbiddenException("You do not have access to this contact's passport") + } + } + + @Get() + async get(@GetAuthContext() auth: AuthContext, @Param('contactId') contactId: string) { + await this.verifySensitiveAccess(contactId, auth) + return this.documentsService.getPassportImage(contactId) + } + + @Post() + @UseInterceptors(FileInterceptor('file')) + async upload( + @GetAuthContext() auth: AuthContext, + @Param('contactId') contactId: string, + @UploadedFile() file: Express.Multer.File, + ) { + await this.verifySensitiveAccess(contactId, auth) + if (!file) throw new BadRequestException('No file provided') + if (file.size > MAX_FILE_SIZE) { + throw new BadRequestException(`File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB`) + } + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + throw new BadRequestException('Invalid file type. Allowed: PDF, JPEG, PNG, GIF, WebP') + } + return this.documentsService.setPassportImage(contactId, { + buffer: file.buffer, + originalName: file.originalname, + mimeType: file.mimetype, + }) + } + + @Delete() + async delete(@GetAuthContext() auth: AuthContext, @Param('contactId') contactId: string) { + await this.verifySensitiveAccess(contactId, auth) + return this.documentsService.deletePassportImage(contactId) + } +} diff --git a/apps/api/src/contacts/contact-passport-image.service.spec.ts b/apps/api/src/contacts/contact-passport-image.service.spec.ts new file mode 100644 index 000000000..2c60c1def --- /dev/null +++ b/apps/api/src/contacts/contact-passport-image.service.spec.ts @@ -0,0 +1,111 @@ +/** + * #946 — passport SCAN IMAGE service logic + null-guards. + * + * Covers the single-slot upload/sign/delete on contacts.* (NOT a + * contact_documents row): upload signs + replaces (deleting the prior key), + * read signs only when a key exists (never sign null), delete is null-safe. + */ + +import { Test, TestingModule } from '@nestjs/testing' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { ContactDocumentsService } from './contact-documents.service' +import { DatabaseService } from '../db/database.service' +import { StorageService } from '../trips/storage.service' + +describe('ContactDocumentsService — passport image (#946)', () => { + let service: ContactDocumentsService + let limitResult: unknown[] + let setArgs: Array> + let storage: { + uploadDocument: jest.Mock + getSignedUrl: jest.Mock + deleteDocument: jest.Mock + isAvailable: jest.Mock + } + + beforeEach(async () => { + limitResult = [] + setArgs = [] + const dbClient = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockImplementation(() => Promise.resolve(limitResult)), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockImplementation((v: Record) => { + setArgs.push(v) + return { where: jest.fn().mockResolvedValue(undefined) } + }), + } + storage = { + uploadDocument: jest.fn().mockResolvedValue('contacts/c1/passport/123-scan.png'), + getSignedUrl: jest.fn().mockResolvedValue('https://signed.example/scan'), + deleteDocument: jest.fn().mockResolvedValue(undefined), + isAvailable: jest.fn().mockReturnValue(true), + } + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ContactDocumentsService, + { provide: EventEmitter2, useValue: { emit: jest.fn() } }, + { provide: StorageService, useValue: storage }, + { + provide: DatabaseService, + useValue: { + client: dbClient, + schema: { + contacts: { id: 'id', passportImageKey: 'passport_image_key', passportImageUploadedAt: 'passport_image_uploaded_at', updatedAt: 'updated_at' }, + }, + }, + }, + ], + }).compile() + service = module.get(ContactDocumentsService) + }) + + it('uploads, signs, and persists the key (no prior scan → no delete)', async () => { + limitResult = [{ id: 'c1', passportImageKey: null }] + const res = await service.setPassportImage('c1', { buffer: Buffer.from('x'), originalName: 'scan.png', mimeType: 'image/png' }) + expect(storage.uploadDocument).toHaveBeenCalledTimes(1) + expect(setArgs[0]).toMatchObject({ passportImageKey: 'contacts/c1/passport/123-scan.png' }) + expect(setArgs[0]?.passportImageUploadedAt).toBeInstanceOf(Date) + expect(storage.deleteDocument).not.toHaveBeenCalled() // no prior scan + expect(res.url).toBe('https://signed.example/scan') + expect(res.uploadedAt).toEqual(expect.any(String)) + }) + + it('deletes the prior scan when replacing', async () => { + limitResult = [{ id: 'c1', passportImageKey: 'contacts/c1/passport/old.png' }] + await service.setPassportImage('c1', { buffer: Buffer.from('x'), originalName: 'new.png', mimeType: 'image/png' }) + expect(storage.deleteDocument).toHaveBeenCalledWith('contacts/c1/passport/old.png') + }) + + it('getPassportImage signs only when a key exists', async () => { + limitResult = [{ passportImageKey: 'contacts/c1/passport/x.png', passportImageUploadedAt: new Date('2026-06-16T00:00:00Z') }] + const res = await service.getPassportImage('c1') + expect(storage.getSignedUrl).toHaveBeenCalledWith('contacts/c1/passport/x.png') + expect(res.url).toBe('https://signed.example/scan') + expect(res.uploadedAt).toBe('2026-06-16T00:00:00.000Z') + }) + + it('getPassportImage returns null + never signs when no scan (null-safe)', async () => { + limitResult = [{ passportImageKey: null, passportImageUploadedAt: null }] + const res = await service.getPassportImage('c1') + expect(res).toEqual({ url: null, uploadedAt: null }) + expect(storage.getSignedUrl).not.toHaveBeenCalled() + }) + + it('deletePassportImage nulls the slot and removes the R2 object', async () => { + limitResult = [{ id: 'c1', passportImageKey: 'contacts/c1/passport/x.png' }] + const res = await service.deletePassportImage('c1') + expect(setArgs[0]).toMatchObject({ passportImageKey: null, passportImageUploadedAt: null }) + expect(storage.deleteDocument).toHaveBeenCalledWith('contacts/c1/passport/x.png') + expect(res).toEqual({ deleted: true }) + }) + + it('deletePassportImage is null-safe when there is no scan (no R2 delete)', async () => { + limitResult = [{ id: 'c1', passportImageKey: null }] + await service.deletePassportImage('c1') + expect(storage.deleteDocument).not.toHaveBeenCalled() + }) +}) diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index aed7507bd..24ed37970 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -19,6 +19,7 @@ import { ContactSharesService } from './contact-shares.service' import { ContactAccessService } from './contact-access.service' import { ContactDocumentsController } from './contact-documents.controller' import { ContactDocumentsService } from './contact-documents.service' +import { ContactPassportImageController } from './contact-passport-image.controller' import { ContactLoyaltyProgramsController } from './contact-loyalty-programs.controller' import { ContactLoyaltyProgramsService } from './contact-loyalty-programs.service' import { ContactShareRequestsController } from './contact-share-requests.controller' @@ -48,6 +49,7 @@ import { TripsModule } from '../trips/trips.module' ContactGroupsController, ContactSharesController, ContactDocumentsController, + ContactPassportImageController, ContactLoyaltyProgramsController, ContactShareRequestsController, ], diff --git a/apps/api/src/portal/portal.controller.ts b/apps/api/src/portal/portal.controller.ts index a89acbbd2..e7be0534f 100644 --- a/apps/api/src/portal/portal.controller.ts +++ b/apps/api/src/portal/portal.controller.ts @@ -114,6 +114,48 @@ export class PortalController { return this.portalService.deletePortalAvatar(auth.userId) } + /** + * #946: passport SCAN IMAGE — attached to the passport field-data (NOT a + * contact_documents row). Single slot; signed-URL view; null-safe. + * GET / POST (upload/replace) / DELETE /portal/me/passport-image + */ + @Get('me/passport-image') + async getPassportImage(@GetPortalAuth() auth: PortalAuthContext) { + return this.portalService.getPortalPassportImage(auth.userId) + } + + @Post('me/passport-image') + @HttpCode(HttpStatus.OK) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('file')) + async uploadPassportImage( + @GetPortalAuth() auth: PortalAuthContext, + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB + new FileTypeValidator({ fileType: /(jpg|jpeg|png|gif|webp|pdf)$/ }), + ], + }), + ) + file: Express.Multer.File, + ) { + if (!file) { + throw new BadRequestException('No file uploaded') + } + return this.portalService.uploadPortalPassportImage( + auth.userId, + file.buffer, + file.originalname, + file.mimetype, + ) + } + + @Delete('me/passport-image') + async deletePassportImage(@GetPortalAuth() auth: PortalAuthContext) { + return this.portalService.deletePortalPassportImage(auth.userId) + } + /** * Get trips for portal user * GET /portal/my-trips diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts index cdad49ddd..921b1b547 100644 --- a/apps/api/src/portal/portal.service.ts +++ b/apps/api/src/portal/portal.service.ts @@ -388,6 +388,93 @@ export class PortalService { } } + // =========================================================================== + // #946 — Passport SCAN IMAGE (portal side). Single-slot asset on contacts.* + // (NOT a contact_documents row — preserves the #906 invariant). Stores the R2 + // key; signs on read. Mirrors the admin ContactDocumentsService passport-image + // methods, scoped to the portal user's own contact. + // =========================================================================== + + /** Upload/replace the portal user's passport scan. Returns a signed URL. */ + async uploadPortalPassportImage( + portalUserId: string, + file: Buffer, + fileName: string, + contentType: string, + ): Promise<{ url: string | null; uploadedAt: string | null }> { + const contact = await this.findContactByPortalUser(portalUserId) + const [{ passportImageKey: oldKey } = { passportImageKey: null }] = await this.db.client + .select({ passportImageKey: this.db.schema.contacts.passportImageKey }) + .from(this.db.schema.contacts) + .where(eq(this.db.schema.contacts.id, contact.id)) + .limit(1) + + const newKey = await this.storageService.uploadDocument( + file, + `contacts/${contact.id}/passport`, + fileName, + contentType, + ) + const uploadedAt = new Date() + try { + await this.db.client + .update(this.db.schema.contacts) + .set({ passportImageKey: newKey, passportImageUploadedAt: uploadedAt, updatedAt: uploadedAt }) + .where(eq(this.db.schema.contacts.id, contact.id)) + } catch (error) { + try { await this.storageService.deleteDocument(newKey) } catch (e) { + this.logger.warn(`Failed to clean up orphaned passport upload ${newKey}: ${e}`) + } + throw error + } + if (oldKey && oldKey !== newKey) { + try { await this.storageService.deleteDocument(oldKey) } catch (e) { + this.logger.warn(`Failed to delete previous passport scan ${oldKey}: ${e}`) + } + } + const url = await this.storageService.getSignedUrl(newKey).catch(() => null) + return { url, uploadedAt: uploadedAt.toISOString() } + } + + /** Signed view URL for the portal user's passport scan (null when none). */ + async getPortalPassportImage( + portalUserId: string, + ): Promise<{ url: string | null; uploadedAt: string | null }> { + const contact = await this.findContactByPortalUser(portalUserId) + const [row] = 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, contact.id)) + .limit(1) + if (!row?.passportImageKey) return { url: null, uploadedAt: null } + const url = await this.storageService.getSignedUrl(row.passportImageKey).catch(() => null) + return { url, uploadedAt: row.passportImageUploadedAt?.toISOString() ?? null } + } + + /** Remove the portal user's passport scan (DB + best-effort R2). */ + async deletePortalPassportImage(portalUserId: string): Promise<{ deleted: boolean }> { + const contact = await this.findContactByPortalUser(portalUserId) + const [row] = await this.db.client + .select({ passportImageKey: this.db.schema.contacts.passportImageKey }) + .from(this.db.schema.contacts) + .where(eq(this.db.schema.contacts.id, contact.id)) + .limit(1) + const key = row?.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, contact.id)) + if (key) { + try { await this.storageService.deleteDocument(key) } catch (e) { + this.logger.warn(`Failed to delete passport scan ${key} from storage: ${e}`) + } + } + return { deleted: true } + } + /** * Get trips linked to this portal contact */ diff --git a/apps/client/src/app/(dashboard)/travelers/page.tsx b/apps/client/src/app/(dashboard)/travelers/page.tsx index f17303217..6e81d8332 100644 --- a/apps/client/src/app/(dashboard)/travelers/page.tsx +++ b/apps/client/src/app/(dashboard)/travelers/page.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from "react"; import { ArrowLeft, Camera, Trash2, Save, Loader2, Plus, Pencil, Award } from "lucide-react"; import Link from "next/link"; import { PortalApiError } from "@/lib/api"; +import { PassportScan } from "@/components/documents/passport-scan"; import { usePortalProfile, useUpdatePortalProfile, @@ -318,6 +319,8 @@ export default function ProfilePage() { updateField("redressNumber", v)} /> updateField("knownTravelerNumber", v)} placeholder="TSA PreCheck / Global Entry" /> + {/* #946: passport scan image (single-slot on contacts.* — no doc row) */} + diff --git a/apps/client/src/components/documents/passport-scan.tsx b/apps/client/src/components/documents/passport-scan.tsx new file mode 100644 index 000000000..b4bebe9a0 --- /dev/null +++ b/apps/client/src/components/documents/passport-scan.tsx @@ -0,0 +1,113 @@ +'use client' + +/** + * #946: Passport SCAN IMAGE manager (client portal). + * + * Single-slot asset on the passport field-data (contacts.*) — NOT a + * contact_documents row (preserves the #906 invariant). View (signed URL), + * upload, replace, delete, with mobile camera capture (capture=environment). + * No toast system in the portal — uses inline status text. + */ + +import { useRef, useState } from 'react' +import { Loader2, Upload, Camera, Trash2, FileText, ExternalLink } from 'lucide-react' +import { + usePassportImage, + useUploadPassportImage, + useDeletePassportImage, +} from '@/hooks/use-portal-documents' +import { Button } from '@tailfire/ui-public' + +const MAX_MB = 10 + +export function PassportScan() { + const { data, isLoading } = usePassportImage() + const uploadMutation = useUploadPassportImage() + const deleteMutation = useDeletePassportImage() + const fileInputRef = useRef(null) + const cameraInputRef = useRef(null) + const [error, setError] = useState('') + + const pending = uploadMutation.isPending || deleteMutation.isPending + + async function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0] + e.target.value = '' + if (!file) return + setError('') + if (file.size > MAX_MB * 1024 * 1024) { + setError(`File must be under ${MAX_MB}MB`) + return + } + try { + await uploadMutation.mutateAsync(file) + } catch (err) { + setError((err as Error).message || 'Upload failed. Please try again.') + } + } + + async function handleDelete() { + setError('') + try { + await deleteMutation.mutateAsync() + } catch (err) { + setError((err as Error).message || 'Could not remove. Please try again.') + } + } + + const url = data?.url ?? null + + return ( +
+

Passport Scan

+

+ Optionally upload a photo of your passport — only you and your advisor can see it. +

+ + + {/* capture=environment opens the rear camera on mobile */} + + + {isLoading ? ( +

Loading…

+ ) : url ? ( +
+ + + View passport scan + + +
+ + + +
+
+ ) : ( +
+ + +
+ )} + + {error &&

{error}

} +
+ ) +} diff --git a/apps/client/src/hooks/use-portal-documents.ts b/apps/client/src/hooks/use-portal-documents.ts index 8731c21f4..ebd50ca53 100644 --- a/apps/client/src/hooks/use-portal-documents.ts +++ b/apps/client/src/hooks/use-portal-documents.ts @@ -68,3 +68,45 @@ export function useDeleteDocument() { }, }) } + +// =========================================================================== +// #946 — Passport SCAN IMAGE (portal). 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 +} + +export function usePassportImage() { + return useQuery({ + queryKey: ['portal', 'passport-image'], + queryFn: () => portalApi('/portal/me/passport-image'), + }) +} + +export function useUploadPassportImage() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (file: File) => { + const formData = new FormData() + formData.append('file', file) + return portalApiMultipart('/portal/me/passport-image', formData) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portal', 'passport-image'] }) + }, + }) +} + +export function useDeletePassportImage() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => + portalApi<{ deleted: boolean }>('/portal/me/passport-image', { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portal', 'passport-image'] }) + }, + }) +} diff --git a/packages/database/src/migrations/20260617150000_946_contact_passport_image.sql b/packages/database/src/migrations/20260617150000_946_contact_passport_image.sql new file mode 100644 index 000000000..33ef473e0 --- /dev/null +++ b/packages/database/src/migrations/20260617150000_946_contact_passport_image.sql @@ -0,0 +1,13 @@ +-- #946: optional passport SCAN IMAGE attached to the passport field-data. +-- +-- Preserves the #906 invariant: passport stays field-data on contacts.* — the +-- scan is a single-slot asset ON the passport record, NOT a passport-typed +-- contact_documents row. We store the R2 storage KEY (not a URL) + sign on read +-- (mirrors contact_documents.file_url); the key is also the stable handle the +-- #947 OCR pass uses to fetch the image. One scan per contact — replacing +-- overwrites the slot (the prior R2 object is deleted by the endpoint first). +-- +-- Replay-clean: ADD COLUMN IF NOT EXISTS is idempotent. + +ALTER TABLE contacts ADD COLUMN IF NOT EXISTS passport_image_key text; +ALTER TABLE contacts ADD COLUMN IF NOT EXISTS passport_image_uploaded_at timestamptz; diff --git a/packages/database/src/migrations/meta/_journal.json b/packages/database/src/migrations/meta/_journal.json index 21111ac3a..ca642f0d5 100644 --- a/packages/database/src/migrations/meta/_journal.json +++ b/packages/database/src/migrations/meta/_journal.json @@ -2066,6 +2066,13 @@ "when": 1781629200001, "tag": "20260617110000_574_tour_search_indexes", "breakpoints": true + }, + { + "idx": 295, + "version": "7", + "when": 1781643600001, + "tag": "20260617150000_946_contact_passport_image", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema/contacts.schema.ts b/packages/database/src/schema/contacts.schema.ts index 3522b5622..a523160ca 100644 --- a/packages/database/src/schema/contacts.schema.ts +++ b/packages/database/src/schema/contacts.schema.ts @@ -102,6 +102,13 @@ export const contacts = pgTable('contacts', { redressNumber: varchar('redress_number', { length: 20 }), knownTravelerNumber: varchar('known_traveler_number', { length: 20 }), // TSA PreCheck, Global Entry, NEXUS + // #946: optional passport SCAN IMAGE — single-slot asset on the passport + // record (NOT a contact_documents row; preserves the #906 invariant). Stores + // the R2 storage KEY (sign on read); also the stable handle the #947 OCR pass + // fetches the image by. + passportImageKey: text('passport_image_key'), + passportImageUploadedAt: timestamp('passport_image_uploaded_at', { withTimezone: true }), + // Address Fields addressLine1: varchar('address_line1', { length: 255 }), addressLine2: varchar('address_line2', { length: 255 }), From 76b1ce9d395e604b76062773da818019d75814a5 Mon Sep 17 00:00:00 2001 From: Al Date: Tue, 16 Jun 2026 20:50:40 -0400 Subject: [PATCH 2/2] fix(#948): CAS slot-replace + redact PII from passport-image logs (Codex CBM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (B) CONCURRENT-REPLACE ORPHAN: passport-image slot update is now a compare-and-set (UPDATE ... WHERE passport_image_key = , or IS NULL when no prior). On CAS miss (a concurrent replace won the slot) we delete OUR just-uploaded blob instead of orphaning the winner's, and throw 409 to retry. Applied to both the admin ContactDocumentsService and portal PortalService copies. (C) PII-IN-LOGS: cleanup/delete-failure logger.warn no longer embeds the R2 key (which carries the sanitized user filename) — redacted to contactId only. (A) DISMISSED (PDF intentionally allowed) — controller comments now say so. Spec: +1 CAS-miss test (7/7). Date-render guard clean. Admin+Client tsc clean. Co-Authored-By: Claude Opus 4.8 --- .../src/contacts/contact-documents.service.ts | 33 +++++++++++++++---- .../contact-passport-image.controller.ts | 2 ++ .../contact-passport-image.service.spec.ts | 26 ++++++++++++++- apps/api/src/portal/portal.controller.ts | 2 ++ apps/api/src/portal/portal.service.ts | 31 +++++++++++++---- 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/apps/api/src/contacts/contact-documents.service.ts b/apps/api/src/contacts/contact-documents.service.ts index 48c67912f..2504fb9cd 100644 --- a/apps/api/src/contacts/contact-documents.service.ts +++ b/apps/api/src/contacts/contact-documents.service.ts @@ -5,8 +5,8 @@ * Works with the contact_documents table. */ -import { Injectable, Logger, NotFoundException } 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' @@ -75,22 +75,41 @@ export class ContactDocumentsService { file.mimeType, ) const uploadedAt = new Date() + let updated: { id: string }[] try { - await this.db.client + // 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(eq(this.db.schema.contacts.id, contactId)) + .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 ${newKey}: ${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 ${oldKey}: ${e}`) + this.logger.warn(`Failed to delete previous passport scan for contact ${contactId}: ${e}`) } } const url = await this.storageService.getSignedUrl(newKey).catch(() => null) @@ -128,7 +147,7 @@ export class ContactDocumentsService { .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 ${key} from storage: ${e}`) + this.logger.warn(`Failed to delete passport scan for contact ${contactId} from storage: ${e}`) } } return { deleted: true } diff --git a/apps/api/src/contacts/contact-passport-image.controller.ts b/apps/api/src/contacts/contact-passport-image.controller.ts index ea46b2f92..6ff66371e 100644 --- a/apps/api/src/contacts/contact-passport-image.controller.ts +++ b/apps/api/src/contacts/contact-passport-image.controller.ts @@ -30,6 +30,8 @@ import type { AuthContext } from '../auth/auth.types' const MAX_FILE_SIZE = 10 * 1024 * 1024 // A passport SCAN is an image or a PDF — narrower than the documents endpoint. +// PDF is intentionally allowed (scanners/phones commonly export passport scans +// as PDF); this is by design, not an oversight. const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp'] @ApiTags('Contact Passport Image') diff --git a/apps/api/src/contacts/contact-passport-image.service.spec.ts b/apps/api/src/contacts/contact-passport-image.service.spec.ts index 2c60c1def..e72d4aa53 100644 --- a/apps/api/src/contacts/contact-passport-image.service.spec.ts +++ b/apps/api/src/contacts/contact-passport-image.service.spec.ts @@ -15,6 +15,7 @@ import { StorageService } from '../trips/storage.service' describe('ContactDocumentsService — passport image (#946)', () => { let service: ContactDocumentsService let limitResult: unknown[] + let updateReturning: Array<{ id: string }> let setArgs: Array> let storage: { uploadDocument: jest.Mock @@ -25,6 +26,8 @@ describe('ContactDocumentsService — passport image (#946)', () => { beforeEach(async () => { limitResult = [] + // Compare-and-set UPDATE returns the matched rows; default = slot claimed. + updateReturning = [{ id: 'c1' }] setArgs = [] const dbClient = { select: jest.fn().mockReturnThis(), @@ -34,7 +37,16 @@ describe('ContactDocumentsService — passport image (#946)', () => { update: jest.fn().mockReturnThis(), set: jest.fn().mockImplementation((v: Record) => { setArgs.push(v) - return { where: jest.fn().mockResolvedValue(undefined) } + // `.where(...)` is awaitable (delete path) AND exposes `.returning()` + // (compare-and-set upload path). + const whereResult: { + returning: jest.Mock + then: (resolve: (value: undefined) => unknown) => unknown + } = { + returning: jest.fn().mockImplementation(() => Promise.resolve(updateReturning)), + then: (resolve) => resolve(undefined), + } + return { where: jest.fn().mockReturnValue(whereResult) } }), } storage = { @@ -80,6 +92,18 @@ describe('ContactDocumentsService — passport image (#946)', () => { expect(storage.deleteDocument).toHaveBeenCalledWith('contacts/c1/passport/old.png') }) + it('on concurrent replace (CAS miss) deletes OUR new blob, never the prior, and throws', async () => { + limitResult = [{ id: 'c1', passportImageKey: 'contacts/c1/passport/old.png' }] + updateReturning = [] // a concurrent upload moved the slot off `old.png` + await expect( + service.setPassportImage('c1', { buffer: Buffer.from('x'), originalName: 'new.png', mimeType: 'image/png' }), + ).rejects.toThrow(/concurrent/i) + // Our just-uploaded key is cleaned up; the winner's prior key is untouched. + expect(storage.deleteDocument).toHaveBeenCalledTimes(1) + expect(storage.deleteDocument).toHaveBeenCalledWith('contacts/c1/passport/123-scan.png') + expect(storage.deleteDocument).not.toHaveBeenCalledWith('contacts/c1/passport/old.png') + }) + it('getPassportImage signs only when a key exists', async () => { limitResult = [{ passportImageKey: 'contacts/c1/passport/x.png', passportImageUploadedAt: new Date('2026-06-16T00:00:00Z') }] const res = await service.getPassportImage('c1') diff --git a/apps/api/src/portal/portal.controller.ts b/apps/api/src/portal/portal.controller.ts index e7be0534f..aeff42614 100644 --- a/apps/api/src/portal/portal.controller.ts +++ b/apps/api/src/portal/portal.controller.ts @@ -117,6 +117,8 @@ export class PortalController { /** * #946: passport SCAN IMAGE — attached to the passport field-data (NOT a * contact_documents row). Single slot; signed-URL view; null-safe. + * PDF is intentionally accepted alongside images (scanners/phones often + * export passport scans as PDF) — by design, not an oversight. * GET / POST (upload/replace) / DELETE /portal/me/passport-image */ @Get('me/passport-image') diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts index 921b1b547..d421282f7 100644 --- a/apps/api/src/portal/portal.service.ts +++ b/apps/api/src/portal/portal.service.ts @@ -15,7 +15,7 @@ import { } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { createClient, SupabaseClient } from '@supabase/supabase-js' -import { eq, and, or, ne, desc, asc, inArray, sql } from 'drizzle-orm' +import { eq, and, or, ne, desc, asc, inArray, isNull, sql } from 'drizzle-orm' import { DatabaseService } from '../db/database.service' import { StorageService } from '../trips/storage.service' import { PortalWriteService, CONTACT_EMAIL_CONFLICT } from './portal-write.service' @@ -416,20 +416,39 @@ export class PortalService { contentType, ) const uploadedAt = new Date() + let updated: { id: string }[] try { - await this.db.client + // 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(eq(this.db.schema.contacts.id, contact.id)) + .where( + and( + eq(this.db.schema.contacts.id, contact.id), + oldKey + ? eq(this.db.schema.contacts.passportImageKey, oldKey) + : isNull(this.db.schema.contacts.passportImageKey), + ), + ) + .returning({ id: this.db.schema.contacts.id }) } catch (error) { try { await this.storageService.deleteDocument(newKey) } catch (e) { - this.logger.warn(`Failed to clean up orphaned passport upload ${newKey}: ${e}`) + this.logger.warn(`Failed to clean up orphaned passport upload for contact ${contact.id}: ${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 ${contact.id}: ${e}`) + } + throw new ConflictException('Passport image was updated concurrently; please retry') + } if (oldKey && oldKey !== newKey) { try { await this.storageService.deleteDocument(oldKey) } catch (e) { - this.logger.warn(`Failed to delete previous passport scan ${oldKey}: ${e}`) + this.logger.warn(`Failed to delete previous passport scan for contact ${contact.id}: ${e}`) } } const url = await this.storageService.getSignedUrl(newKey).catch(() => null) @@ -469,7 +488,7 @@ export class PortalService { .where(eq(this.db.schema.contacts.id, contact.id)) if (key) { try { await this.storageService.deleteDocument(key) } catch (e) { - this.logger.warn(`Failed to delete passport scan ${key} from storage: ${e}`) + this.logger.warn(`Failed to delete passport scan for contact ${contact.id} from storage: ${e}`) } } return { deleted: true }