Skip to content

feat(#946): passport scan-image upload (single-slot on contacts.*, preserves #906)#948

Merged
Systemsaholic merged 2 commits into
mainfrom
feature/issue-946-passport-scan-image
Jun 17, 2026
Merged

feat(#946): passport scan-image upload (single-slot on contacts.*, preserves #906)#948
Systemsaholic merged 2 commits into
mainfrom
feature/issue-946-passport-scan-image

Conversation

@Systemsaholic

@Systemsaholic Systemsaholic commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Design (invariant-preserving single-slot scan)

Per the #946 constraint, the passport scan attaches to the passport field-data, not as a competing document row:

Backend (reuses #906 StorageService: uploadDocument/getSignedUrl/deleteDocument; null-safe throughout)

  • Admin: ContactDocumentsService.{setPassportImage,getPassportImage,deletePassportImage} + ContactPassportImageControllerGET/POST/DELETE /contacts/:id/passport-image (sensitive-access guarded, mirrors the documents controller).
  • Portal: PortalService.{upload,get,delete}PortalPassportImageGET/POST/DELETE /portal/me/passport-image (own contact).
  • Null-safe: getSignedUrl never called on a null key; delete skips R2 when there's no scan; upload rolls back the orphan on DB failure.

UI (signed-URL view, replace, delete, mobile camera capture=environment)

Verification

  • Migration replay-clean + staging-rehearsed (\d shows both nullable cols; re-run = no-op).
  • Spec: contact-passport-image.service.spec — upload/sign/replace + read/delete null-guards (6/6).
  • All 3 apps tsc clean.

#947 seam

The scan is retrievable by contacts.passport_image_key (server-side fetch or signed URL) — the OCR pass reads it → Claude Vision → review-confirm (never auto-commit). No rework needed.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added single-slot passport scan image management for contacts, with upload, view, replace (replace scan), and delete available in both admin and portal/mobile user flows
    • Enforced a 10MB upload limit with validation for common image formats plus PDF
    • Displays the current scan via a view link and shows the upload date when available

…eserves #906)

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): <PassportScan> + use-contacts hooks.
- Portal Passport & Travel ID (#906): <PassportScan> + 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 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tailfire-client Ready Ready Preview, Comment Jun 17, 2026 12:52am
tailfire-ota Ready Ready Preview, Comment Jun 17, 2026 12:52am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds a single-slot passport scan image field to contacts. Two new contacts columns (passport_image_key, passport_image_uploaded_at) are introduced via migration. Backend services on both the admin and portal paths handle R2 upload/sign/delete with rollback. Two new API route groups expose GET/POST/DELETE. React Query hooks and PassportScan UI components are added to both the admin contact detail page and the client portal traveler profile page.

Changes

Passport Scan Image Feature

Layer / File(s) Summary
Database schema and migration
packages/database/src/migrations/20260617150000_946_contact_passport_image.sql, packages/database/src/migrations/meta/_journal.json, packages/database/src/schema/contacts.schema.ts
Adds passport_image_key and passport_image_uploaded_at columns to contacts with an idempotent migration, journal entry, and ORM schema update.
ContactDocumentsService and PortalService passport image methods
apps/api/src/contacts/contact-documents.service.ts, apps/api/src/portal/portal.service.ts
Adds PassportImageDto and three methods to ContactDocumentsService (upload with rollback, get with signed URL, delete); mirrors the same logic in PortalService as uploadPortalPassportImage, getPortalPassportImage, deletePortalPassportImage. Both services inject StorageService and perform best-effort old-key cleanup.
Admin and portal API controllers + module wiring
apps/api/src/contacts/contact-passport-image.controller.ts, apps/api/src/contacts/contacts.module.ts, apps/api/src/portal/portal.controller.ts
Adds ContactPassportImageController at contacts/:contactId/passport-image with a sensitive-access guard and file validation, registered in ContactsModule; adds parallel GET/POST/DELETE handlers to PortalController at /portal/me/passport-image.
ContactDocumentsService passport image tests
apps/api/src/contacts/contact-passport-image.service.spec.ts
Jest suite covering no-prior-key upload, replacement (old R2 key deleted), concurrent CAS-miss scenarios, signed-URL retrieval with null safety, and delete with null safety.
Admin React Query hooks and PassportScan UI
apps/admin/src/hooks/use-contacts.ts, apps/admin/src/app/contacts/[id]/_components/passport-scan.tsx, apps/admin/src/app/contacts/[id]/page.tsx
Exports PassportImageResult and three hooks for the admin contact passport image; adds PassportScan component with 10MB limit, ApiError toasts, spinner, and conditional view/replace/delete or upload/take-photo UI rendered in the contact detail page.
Portal React Query hooks and PassportScan UI
apps/client/src/hooks/use-portal-documents.ts, apps/client/src/components/documents/passport-scan.tsx, apps/client/src/app/(dashboard)/travelers/page.tsx
Exports PassportImageResult and three portal hooks; adds a portal PassportScan component with inline error display and the same conditional UI pattern; inserts it into the Travelers "Passport & Travel ID" section.

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant PassportScan as PassportScan (UI)
  participant Hook as useUploadPassportImage
  participant Controller as ContactPassportImageController / PortalController
  participant Service as ContactDocumentsService / PortalService
  participant R2 as StorageService (R2)
  participant DB as contacts table

  User->>PassportScan: selects file (≤10MB, allowed MIME)
  PassportScan->>Hook: mutateAsync(file)
  Hook->>Controller: POST /passport-image (multipart)
  Controller->>Controller: verifySensitiveAccess / PortalAuthGuard
  Controller->>Service: setPassportImage / uploadPortalPassportImage
  Service->>R2: uploadDocument(buffer, key)
  R2-->>Service: storageKey
  Service->>DB: UPDATE contacts SET passport_image_key, passport_image_uploaded_at
  alt DB fails
    Service->>R2: deleteDocument(newKey) [rollback]
  else DB succeeds & prior key existed
    Service->>R2: deleteDocument(oldKey) [best-effort]
  end
  Service->>R2: getSignedUrl(storageKey)
  R2-->>Service: signedUrl
  Service-->>Controller: { url, uploadedAt }
  Controller-->>Hook: 200 { url, uploadedAt }
  Hook->>Hook: invalidate passport-image query
  PassportScan->>User: renders signed URL link + Replace/Delete controls
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Poem

🐇 A passport scan, so crisp and neat,
Uploaded to R2 with a happy beat.
The bunny hops through signed URLs with glee,
Delete old keys? Best-effort — carefree!
Admin and portal, both get their slot,
One image per contact — that's quite a lot! 📷

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing passport scan-image upload with a single-slot design on the contacts table, while preserving the #906 invariant.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/issue-946-passport-scan-image

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

apps/api/src/contacts/contact-documents.service.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: ESLint configuration in --config is invalid:

  • Property "" is the wrong type (expected object but got "import config from \"@tailfire/config/eslint/flat-node\";\nexport default config;").

    at ConfigValidator.validateConfigSchema (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2177:19)
    at ConfigArrayFactory._normalizeConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3019:19)
    at ConfigArrayFactory._loadConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:21)
    at ConfigArrayFactory.loadFile (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
    at createCLIConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)
    at new CascadingConfigArrayFactory (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3735:29)
    at new CLIEngine (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/cli-engine.js:617:36)
    at new ESLint (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/eslint/eslint.js:430:27)
    at Object.execute (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli.js:410:24)
    at async main (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/bin/eslint.js:152:22)

apps/api/src/contacts/contact-passport-image.controller.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: ESLint configuration in --config is invalid:

  • Property "" is the wrong type (expected object but got "import config from \"@tailfire/config/eslint/flat-node\";\nexport default config;").

    at ConfigValidator.validateConfigSchema (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2177:19)
    at ConfigArrayFactory._normalizeConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3019:19)
    at ConfigArrayFactory._loadConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:21)
    at ConfigArrayFactory.loadFile (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
    at createCLIConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)
    at new CascadingConfigArrayFactory (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3735:29)
    at new CLIEngine (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/cli-engine.js:617:36)
    at new ESLint (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/eslint/eslint.js:430:27)
    at Object.execute (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli.js:410:24)
    at async main (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/bin/eslint.js:152:22)

apps/api/src/contacts/contact-passport-image.service.spec.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: ESLint configuration in --config is invalid:

  • Property "" is the wrong type (expected object but got "import config from \"@tailfire/config/eslint/flat-node\";\nexport default config;").

    at ConfigValidator.validateConfigSchema (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2177:19)
    at ConfigArrayFactory._normalizeConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3019:19)
    at ConfigArrayFactory._loadConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:21)
    at ConfigArrayFactory.loadFile (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
    at createCLIConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)
    at new CascadingConfigArrayFactory (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3735:29)
    at new CLIEngine (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/cli-engine.js:617:36)
    at new ESLint (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/eslint/eslint.js:430:27)
    at Object.execute (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli.js:410:24)
    at async main (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/bin/eslint.js:152:22)

  • 2 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
apps/api/src/contacts/contact-passport-image.service.spec.ts (1)

66-111: ⚡ Quick win

Add a rollback regression test for DB update failure after upload.

Please add a case that forces the contact update to throw after uploadDocument succeeds, then asserts deleteDocument(newKey) is called. This protects the orphan-cleanup contract from regressions.

🤖 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/api/src/contacts/contact-passport-image.service.spec.ts` around lines 66
- 111, Add a new test case within the test suite that verifies rollback behavior
when the database update fails after a successful upload in setPassportImage.
The test should mock the contact.set() call to throw an error after
storage.uploadDocument succeeds, then assert that storage.deleteDocument is
called with the newly uploaded key (e.g., 'contacts/c1/passport/123-scan.png').
This ensures the orphan-cleanup contract is protected: if the DB update fails,
the freshly uploaded document is cleaned up to prevent orphaned files in
storage.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@apps/admin/src/app/contacts/`[id]/_components/passport-scan.tsx:
- Around line 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.
- Around line 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.

In `@apps/api/src/contacts/contact-passport-image.controller.ts`:
- Around line 60-70: The FileInterceptor decorator on the upload method is
missing multer limits configuration, allowing large files to be fully buffered
in memory before the size validation check runs. Add a limits configuration
object to the FileInterceptor call that specifies the maximum file size limit
(using MAX_FILE_SIZE) to ensure files exceeding the limit are rejected by multer
before they are fully buffered in memory, preventing potential DoS attacks
through memory exhaustion.

In `@apps/api/src/portal/portal.controller.ts`:
- Around line 130-141: The FileInterceptor decorator on the uploadPassportImage
method needs to be configured with fileSize limits to reject oversized uploads
at the Busboy level before buffering, rather than only validating after
multipart parsing. Update the FileInterceptor('file') decorator to include a
second parameter that specifies limits with a fileSize property set to 10 * 1024
* 1024 (matching the MaxFileSizeValidator limit). This pattern matches the
implementation already in place in media.controller.ts and
ocr-import.controller.ts and will prevent memory exhaustion from large file
uploads under load.

In `@apps/client/src/components/documents/passport-scan.tsx`:
- Around line 38-41: The passport-scan component accepts a broad image/* MIME
type range that permits files the backend rejects (such as HEIC or SVG), causing
confusing upload failures. Add a MIME type validation check in the file size
validation block (near the existing MAX_MB check at lines 38-41) to only allow
backend-supported image formats and reject unsupported types with a clear error
message. Additionally, update the accept attribute on the file input element (at
lines 67-70) from the broad image/* pattern to explicitly list only the
backend-supported MIME types (for example, image/jpeg, image/png) to prevent
users from selecting incompatible files in the first place.

---

Nitpick comments:
In `@apps/api/src/contacts/contact-passport-image.service.spec.ts`:
- Around line 66-111: Add a new test case within the test suite that verifies
rollback behavior when the database update fails after a successful upload in
setPassportImage. The test should mock the contact.set() call to throw an error
after storage.uploadDocument succeeds, then assert that storage.deleteDocument
is called with the newly uploaded key (e.g.,
'contacts/c1/passport/123-scan.png'). This ensures the orphan-cleanup contract
is protected: if the DB update fails, the freshly uploaded document is cleaned
up to prevent orphaned files in storage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 97f668fe-e3db-47e6-b4e5-0ff4ba3537b9

📥 Commits

Reviewing files that changed from the base of the PR and between c17bdbc and de88109.

📒 Files selected for processing (15)
  • apps/admin/src/app/contacts/[id]/_components/passport-scan.tsx
  • apps/admin/src/app/contacts/[id]/page.tsx
  • apps/admin/src/hooks/use-contacts.ts
  • apps/api/src/contacts/contact-documents.service.ts
  • apps/api/src/contacts/contact-passport-image.controller.ts
  • apps/api/src/contacts/contact-passport-image.service.spec.ts
  • apps/api/src/contacts/contacts.module.ts
  • apps/api/src/portal/portal.controller.ts
  • apps/api/src/portal/portal.service.ts
  • apps/client/src/app/(dashboard)/travelers/page.tsx
  • apps/client/src/components/documents/passport-scan.tsx
  • apps/client/src/hooks/use-portal-documents.ts
  • packages/database/src/migrations/20260617150000_946_contact_passport_image.sql
  • packages/database/src/migrations/meta/_journal.json
  • packages/database/src/schema/contacts.schema.ts

Comment on lines +41 to +44
if (file.size > MAX_MB * 1024 * 1024) {
setError(`File must be under ${MAX_MB}MB`)
return
}

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.

Comment on lines +48 to +54
} catch (err) {
if (err instanceof ApiError) {
toast({ title: 'Upload failed', description: err.message, variant: 'destructive' })
return
}
throw err
}

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.

Comment on lines +60 to +70
@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`)
}

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, examine the actual file to verify the current implementation
wc -l apps/api/src/contacts/contact-passport-image.controller.ts

Repository: Systemsaholic/tailfire

Length of output: 128


🏁 Script executed:

# Read the file around the specified lines
cat -n apps/api/src/contacts/contact-passport-image.controller.ts | head -80

Repository: Systemsaholic/tailfire

Length of output: 3545


🏁 Script executed:

# Search for MAX_FILE_SIZE definition in the file
rg "MAX_FILE_SIZE" apps/api/src/contacts/contact-passport-image.controller.ts -B 2 -A 2

Repository: Systemsaholic/tailfire

Length of output: 665


🏁 Script executed:

# Check for other FileInterceptor usages in the codebase to see patterns
rg "FileInterceptor" --type ts --type tsx -A 3 | head -100

Repository: Systemsaholic/tailfire

Length of output: 94


🏁 Script executed:

# Search for FileInterceptor usages in the codebase with proper ts type
rg "FileInterceptor" -A 3 --type ts apps/api/src/ | head -150

Repository: Systemsaholic/tailfire

Length of output: 11598


🏁 Script executed:

# Check if there are any examples of FileInterceptor with limits configuration in codebase
rg "FileInterceptor.*limits" --type ts -A 3

Repository: Systemsaholic/tailfire

Length of output: 484


🏁 Script executed:

# Check NestJS documentation info and common patterns
rg "limits.*fileSize" --type ts

Repository: Systemsaholic/tailfire

Length of output: 386


Add file-size limit to FileInterceptor to prevent buffering oversized uploads.

Line 60 uses FileInterceptor('file') without multer limits, allowing large files to be fully buffered in memory before the handler's size check at line 68 runs. This creates a DoS/memory exhaustion risk. Add limits to the interceptor to fail fast, matching the pattern already used in media.controller.ts and ocr-import.controller.ts.

Suggested patch
-  `@UseInterceptors`(FileInterceptor('file'))
+  `@UseInterceptors`(
+    FileInterceptor('file', {
+      limits: { fileSize: MAX_FILE_SIZE },
+    }),
+  )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@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`)
}
`@UseInterceptors`(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE },
}),
)
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`)
}
🤖 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/api/src/contacts/contact-passport-image.controller.ts` around lines 60 -
70, The FileInterceptor decorator on the upload method is missing multer limits
configuration, allowing large files to be fully buffered in memory before the
size validation check runs. Add a limits configuration object to the
FileInterceptor call that specifies the maximum file size limit (using
MAX_FILE_SIZE) to ensure files exceeding the limit are rejected by multer before
they are fully buffered in memory, preventing potential DoS attacks through
memory exhaustion.

Comment on lines +130 to +141
@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,

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the actual file content at the specified lines
cat -n apps/api/src/portal/portal.controller.ts | sed -n '125,145p'

Repository: Systemsaholic/tailfire

Length of output: 795


🏁 Script executed:

# Also search for other FileInterceptor usages in the codebase to see patterns
rg "FileInterceptor" apps/api/src --type ts -B 2 -A 5

Repository: Systemsaholic/tailfire

Length of output: 22604


🏁 Script executed:

# Search for MAX_FILE_SIZE constant definition
rg "MAX_FILE_SIZE\s*=" apps/api/src --type ts

Repository: Systemsaholic/tailfire

Length of output: 794


🏁 Script executed:

# Check if there's a configuration file for file size limits
find apps/api/src -name "*.ts" -type f | xargs grep -l "fileSize.*limit" | head -5

Repository: Systemsaholic/tailfire

Length of output: 48


🌐 Web query:

NestJS FileInterceptor multer limits fileSize parsing buffering behavior

💡 Result:

In NestJS, the FileInterceptor uses the multer library, which handles file uploads via Busboy [1]. Regarding file size limits and buffering behavior, the following points are critical: Buffering Behavior Multer’s memory usage depends on the storage engine chosen: 1. MemoryStorage (Default): If no storage engine is configured, multer buffers the entire file in memory as a Buffer object [2]. This poses significant risk for large file uploads or concurrent requests, potentially causing the application to run out of memory [3]. 2. DiskStorage: If configured to use disk storage, files are streamed directly to the filesystem [2]. While this is safer for memory, the file is still processed by the server, and the request must complete (or be terminated) before the interceptor finishes its logic and passes control to your controller handler [4]. File Size Limits The fileSize limit in multer (configured within the limits object) is enforced by Busboy, which parses the incoming multipart stream [3]. - Enforcement: When a file exceeds the configured fileSize limit, Busboy emits a limit event [5][6]. Multer catches this, aborts the stream, and typically throws a MulterError (specifically LIMIT_FILE_SIZE) [6][7]. - Timing: Multer does not immediately disconnect the client as soon as the limit is hit; rather, it stops processing the file stream [5]. The browser/client may continue sending data until the request is closed or the server terminates the connection [5]. - Accuracy/Off-by-one: Historically, Multer and its underlying dependency, Busboy, have had issues where files exactly equal to the configured fileSize limit are rejected [6][8]. Recent maintainer efforts (as of 2026) have sought to address this by adjusting the limit passed to Busboy to ensure files exactly at the limit are accepted [8]. Best Practices - Prefer DiskStorage: For production applications, always use diskStorage to prevent memory exhaustion [3][9]. - Validate Early: For custom validation (like specific metadata checks), create a custom FileValidator (or Pipe) as recommended by the official NestJS documentation [10]. This allows you to reject files without processing the entire request body unnecessarily [10]. - Error Handling: Since NestJS FileInterceptor wraps the multer middleware, you must handle the error returned by the interceptor. If using an interceptor, the error is usually caught and transformed into a NestJS-compatible exception (e.g., BadRequestException) [1]. Ensure your exception filters are set up to handle these specific multer-related errors [11][7].

Citations:


Add multer fileSize limits to the FileInterceptor for early rejection.

The endpoint currently validates file size through ParseFilePipe after multipart parsing. Configure FileInterceptor with limits so oversized uploads are rejected by Busboy before buffering, preventing memory exhaustion under load. This pattern is already established in media.controller.ts and ocr-import.controller.ts.

Suggested patch
-  `@UseInterceptors`(FileInterceptor('file'))
+  `@UseInterceptors`(
+    FileInterceptor('file', {
+      limits: { fileSize: 10 * 1024 * 1024 },
+    }),
+  )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@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,
`@UseInterceptors`(
FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
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,
🤖 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/api/src/portal/portal.controller.ts` around lines 130 - 141, The
FileInterceptor decorator on the uploadPassportImage method needs to be
configured with fileSize limits to reject oversized uploads at the Busboy level
before buffering, rather than only validating after multipart parsing. Update
the FileInterceptor('file') decorator to include a second parameter that
specifies limits with a fileSize property set to 10 * 1024 * 1024 (matching the
MaxFileSizeValidator limit). This pattern matches the implementation already in
place in media.controller.ts and ocr-import.controller.ts and will prevent
memory exhaustion from large file uploads under load.

Comment on lines +38 to +41
if (file.size > MAX_MB * 1024 * 1024) {
setError(`File must be under ${MAX_MB}MB`)
return
}

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

Restrict/validate selected file types to the backend-supported MIME set.

At Line 67-70, broad image/* permits file types the API rejects (e.g., HEIC/SVG depending on device/browser). Add a MIME check near Line 38-41 and tighten accept values to reduce failed uploads and confusing retries.

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: 67-70

🤖 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/client/src/components/documents/passport-scan.tsx` around lines 38 - 41,
The passport-scan component accepts a broad image/* MIME type range that permits
files the backend rejects (such as HEIC or SVG), causing confusing upload
failures. Add a MIME type validation check in the file size validation block
(near the existing MAX_MB check at lines 38-41) to only allow backend-supported
image formats and reject unsupported types with a clear error message.
Additionally, update the accept attribute on the file input element (at lines
67-70) from the broad image/* pattern to explicitly list only the
backend-supported MIME types (for example, image/jpeg, image/png) to prevent
users from selecting incompatible files in the first place.

…dex CBM)

(B) CONCURRENT-REPLACE ORPHAN: passport-image slot update is now a
compare-and-set (UPDATE ... WHERE passport_image_key = <oldKey>, 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 <noreply@anthropic.com>
@Systemsaholic Systemsaholic merged commit f2e0f02 into main Jun 17, 2026
11 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant