Skip to content

IMM-02 · Client form and documents (portal)

SOP: SOP_Immigration_Applications.md §4 / §8 Step 3Actors: Applicant / Client Submitter — opens a magic link, no login. Pre-state: Run is AWAITING_CLIENT with an ISSUED/OPENED FormInstance (see IMM-01). Post-state: Required documents uploaded as KycDocument rows; FormInstance flipped to SUBMITTED; agent.run-form-resume enqueued so the paused run continues to the next steps.

0. Prerequisites

  • A FormInstance magic link for the run. In dev, mint or read the token — the JWT is not persisted, so capture it from the worker logs / form-issue path, or use the resolveForm flow against a known token.
  • The portal page is /portal/imm/[token] (web); the API prefix is /portal/imm.

1. Steps

1.1 Open the form

Navigate to http://localhost:3000/portal/imm/<token>. The page calls GET /portal/imm/:token, which:

  • Verifies the token (resourceType = IMM_FORM_INSTANCE, carries runId slot = formInstanceId, filingId slot = tenantId).
  • Resolves the FormInstance by id under withTenantContext, checks magic_token_hash, and on first open flips ISSUED → OPENED.
  • Returns the form title, passType, prefilledData, submittedData, the fields (with prefillOnly flags), and the requiredDocs checklist (each with uploaded + fileId).

Prefilled, locked fields (pass type, employer, salary) are read-only; the applicant fills the rest (passport number, nationality, DOB, and pass-type-specific fields — see SOP §4).

1.2 Upload each required document

For each checklist slot, choose a file. The page POSTs multipart to POST /portal/imm/:token/files with a slotKey field:

POST /portal/imm/:token/files
Content-Type: multipart/form-data
  file=<binary>
  slotKey=passport_bio_page

The handler:

  1. Rejects an unknown slotKey (400) or a missing file/slotKey (400); 413 on a file > 25 MB.
  2. PUTs the bytes to S3 at imm/<runId>/docs/<ts>_<name>.
  3. Records a File row (bucket, objectKey, sha-256 checksum) and a KycDocument on the run's KycCase (created if absent), with verification_status = UPLOADED:<slotKey>. Re-uploading the same slot replaces the prior file.
  4. Returns 201 { fileId, kycDocumentId, slotKey }.

1.3 Save a draft (optional, save-and-resume)

http
PATCH /portal/imm/:token
{ "submittedData": { "passportNumber": "...", "nationality": "..." } }

Saves the partial form data and keeps status OPENED; the applicant can return to the same link later.

1.4 Submit

http
POST /portal/imm/:token/submit
{ "submittedData": { ...all fields... } }

The handler validates every required-doc slot has a file (400 listing the missing labels otherwise), flips the FormInstance to SUBMITTED, then enqueues agent.run-form-resume with { tenantId, runId, formInstanceId }. Returns 200 { status: "submitted" }.

2. Verification

Database

sql
SELECT id, status, submitted_data IS NOT NULL AS has_data
  FROM form_instances WHERE run_id = '<runId>';
-- after submit: status = 'SUBMITTED'

SELECT id, doc_type, verification_status, file_id IS NOT NULL AS has_file
  FROM kyc_documents kd
  JOIN kyc_cases kc ON kc.id = kd.kyc_case_id
  WHERE kc.source_run_id = '<runId>';
-- one row per uploaded slot, verification_status = 'UPLOADED:<slotKey>'

S3 / MinIO (http://localhost:9001)

  • Objects under imm/<runId>/docs/ — one per uploaded document.

Worker

  • Within seconds, agent.run-form-resume fires: the run transitions AWAITING_CLIENT → RUNNING and steps collectDocuments … submit execute. Watch for agent.run.form_resumed in worker logs.

3. Negative & edge cases

  • Expired link (> 48h) → GET flips the form to EXPIRED and returns 404 Form link not found or expired.
  • Submit with missing docs → 400 Please upload the required documents before submitting: <labels>. Status stays unsubmitted.
  • Upload after submit → 400 Form already submitted.
  • Duplicate submit / job retry → the resume handler is a defensive no-op if the run is no longer AWAITING_CLIENT (agent.run.form_resume.skipped).

Next

Proceed to IMM-03 · Eligibility and review.

Internal use only — BreezyCorp