Files
MOPC-Portal/docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md
Matt 289903c8bd feat: schema for visa tracking (additive)
Adds VisaStatus enum, VisaApplication model 1:1 with AttendingMember
(cascade-delete), and Program.visaStatusVisibleToMembers Boolean
toggle. The model intentionally stores process metadata only — status,
optional nationality, key dates, free-text notes. Sensitive documents
(passport scans, invitation letters, decision papers) continue to flow
over email and are never persisted in the platform.

Migration is purely additive: CREATE TYPE / CREATE TABLE / ADD COLUMN /
ADD FK. No DROP / ALTER on existing data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:28:53 +02:00

9.8 KiB

PR 4: Visa Tracking Implementation Plan

For agentic workers: Steps use checkbox (- [ ]) syntax for tracking.

Goal: Track the visa-application lifecycle for grand-finale attendees who need a visa, without ever holding sensitive documents (passport scans, visa letters). Documents continue to flow over email; the platform records process metadata only.

Architecture: A new VisaApplication model 1:1 with AttendingMember, auto-created when needsVisa=true flips on. Compact 5-stage status enum. A Program.visaStatusVisibleToMembers toggle gates whether teams see their own status. Auto-create / auto-cleanup runs in the same writes as confirm / adminConfirm / editAttendees so the lifecycle stays in sync.

Tech Stack: Prisma 6 (additive migration), tRPC (router additions to logistics + applicant), Vitest 4 for TDD, shadcn/ui for the admin tab + member badge.


Task 1: Schema migration (additive)

Files:

  • Modify: prisma/schema.prisma

  • Create: prisma/migrations/<timestamp>_add_visa_tracking/migration.sql

  • Step 1: Add the enum + model + program toggle

enum VisaStatus {
  NOT_NEEDED
  REQUESTED
  INVITATION_SENT
  APPOINTMENT_BOOKED
  GRANTED
  DENIED
}

model VisaApplication {
  id                String     @id @default(cuid())
  attendingMemberId String     @unique
  status            VisaStatus @default(REQUESTED)
  nationality       String?    // self-declared, optional
  invitationSentAt  DateTime?
  appointmentAt     DateTime?
  decisionAt        DateTime?  // GRANTED or DENIED date
  notes             String?    @db.Text
  createdAt         DateTime   @default(now())
  updatedAt         DateTime   @updatedAt

  attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)

  @@index([status])
}

Add the back-reference on AttendingMember:

visaApplication VisaApplication?

Add to Program:

visaStatusVisibleToMembers Boolean @default(true)
  • Step 2: Generate migration with --create-only and inspect the SQL for any non-additive operations (DROP COLUMN / ALTER COLUMN / RENAME). Should be only CREATE TYPE, CREATE TABLE, ALTER TABLE Program ADD COLUMN, foreign keys.

Run: npx prisma migrate dev --name add_visa_tracking --create-only Then: read migration SQL, verify it's safe.

  • Step 3: Apply migration + regenerate client

Run: npx prisma migrate dev (apply pending) and npx prisma generate.

  • Step 4: Commit.

Task 2: Auto-create / cleanup VisaApplication on attendee writes (TDD)

Files:

  • Modify: src/server/routers/finalist.ts (confirm, adminConfirm, editAttendees)

  • Create: tests/unit/visa-application-lifecycle.test.ts

  • Step 1: Write failing tests

describe('VisaApplication lifecycle', () => {
  it('public confirm creates a VisaApplication for each needsVisa=true attendee', async () => {
    // setup: PENDING confirmation, 2 team members
    // call confirm with both attending, visaFlags { lead: false, member: true }
    // assert: 1 VisaApplication with status=REQUESTED for member
  })

  it('adminConfirm creates a VisaApplication for each needsVisa=true attendee', async () => {
    // same as above but via adminConfirm
  })

  it('editAttendees creates a VisaApplication when an attendee flips to needsVisa=true', async () => {
    // setup: CONFIRMED with 1 attendee (lead) needsVisa=false, no VisaApp
    // call editAttendees with same attendees but visaFlags { lead: true }
    // assert: 1 VisaApplication for lead
  })

  it('editAttendees deletes the VisaApplication when an attendee flips to needsVisa=false', async () => {
    // setup: CONFIRMED with 1 attendee needsVisa=true + VisaApp exists
    // call editAttendees same roster but visaFlags { lead: false }
    // assert: 0 VisaApplications
  })

  it('editAttendees preserves an existing VisaApplication when needsVisa stays true', async () => {
    // setup: VisaApp with notes='abc', status=APPOINTMENT_BOOKED
    // call editAttendees same roster + visaFlags unchanged
    // assert: VisaApp unchanged (notes still 'abc', status still APPOINTMENT_BOOKED)
  })

  it('removing an attendee cascades the VisaApplication', async () => {
    // setup: CONFIRMED with 2 attendees, both needsVisa=true with VisaApp rows
    // call editAttendees roster of just the lead
    // assert: only 1 VisaApp left (for lead)
  })
})
  • Step 2: Run tests, expect 6 failures.

  • Step 3: Wire auto-create in confirm (public)

After the existing attendingMember.createMany, add a follow-up createMany for any user with visaFlags[uid] === true:

// inside the same $transaction
ctx.prisma.visaApplication.createMany({
  data: input.attendingUserIds
    .filter((uid) => input.visaFlags[uid] === true)
    .map((uid) => /* will need attendingMemberId — use a separate post-tx pass */),
})

Note: createMany won't have the attendingMemberId until after the AttendingMember rows are created. Easiest: run the visa creation in a follow-up step (after the transaction commits) by re-querying the attendee rows. The transaction guarantees the attendees are committed; if the visa step fails the worst case is missing visa rows that the admin can manually re-create — but to be safer wrap both in a single Prisma transaction using $transaction(async (tx) => {...}) callback form.

  • Step 4: Wire auto-create in adminConfirm — same pattern.

  • Step 5: Wire diff-aware sync in editAttendees

After the existing diff transaction, derive the desired needsVisa=true set, the existing VisaApplication set, and:

  • Create rows for new needsVisa=true attendees with no VisaApp

  • Delete rows for attendees whose needsVisa flipped to false (or whose AttendingMember was deleted — already cascaded)

  • Leave alone rows where needsVisa stays true (preserves notes / status)

  • Step 6: Run tests, expect green.

  • Step 7: Commit.


Task 3: Admin visa CRUD procedures (TDD)

Files:

  • Modify: src/server/routers/logistics.ts (add 3 procedures)

  • Create: tests/unit/visa-admin.test.ts

  • Step 1: Write failing tests

describe('logistics.listVisaApplications', () => {
  it('returns rows joined with project + attendee for the program, sorted by status priority', async () => {
    // 3 visa apps: GRANTED, REQUESTED, APPOINTMENT_BOOKED
    // expect order: REQUESTED, INVITATION_SENT (none), APPOINTMENT_BOOKED, GRANTED
  })
})

describe('logistics.updateVisaApplication', () => {
  it('updates status + dates + notes + nationality', async () => {
    // setup: REQUESTED app
    // update -> APPOINTMENT_BOOKED + appointmentAt + notes
    // assert: row updated, audit log VISA_UPDATE written
  })

  it('rejects an unknown application id', async () => {
    // expect throw /not found/i
  })
})

describe('logistics.setVisaVisibility', () => {
  it('flips Program.visaStatusVisibleToMembers', async () => {
    // default true -> set false -> verify
  })
})
  • Step 2: Implement the three procedures in logistics.ts.

  • Step 3: Run tests, expect green.

  • Step 4: Commit.


Task 4: Member visa query (TDD)

Files:

  • Modify: src/server/routers/applicant.ts

  • Modify: tests/unit/visa-admin.test.ts (add a describe block) OR new file tests/unit/visa-member-visibility.test.ts

  • Step 1: Write failing tests

describe('applicant.getMyVisaApplications', () => {
  it('returns the caller-team visa apps when toggle is true', async () => {
    // setup: program toggle=true, member with VisaApp
    // assert: returns array with that app
  })

  it('returns null when toggle is false', async () => {
    // assert: returns null
  })

  it('returns empty array when caller has no visa apps', async () => {
    // assert: []
  })
})
  • Step 2: Implement — query AttendingMember rows where userId = caller, include VisaApplication. Return null if program.visaStatusVisibleToMembers === false.

  • Step 3: Commit.


Task 5: Admin Visas tab UI

Files:

  • Modify: src/app/(admin)/admin/logistics/page.tsx (un-disable the Visas tab)

  • Create: src/components/admin/logistics/visas-tab.tsx

  • Create: src/components/admin/logistics/visa-edit-dialog.tsx

  • Step 1: Build the tab

Table columns: Project · Member · Nationality · Status · Next date · Notes (truncated) · Actions. Status filter pills mirror the Confirmations tab. Header has a "Visible to teams" Switch wired to setVisaVisibility.

  • Step 2: Build the edit dialog

Status dropdown, nationality input, three date pickers (invitation / appointment / decision), notes textarea. Save calls updateVisaApplication.

  • Step 3: Activate the tab — remove the disabled prop and wire <VisasTab programId={programId} />.

  • Step 4: Live smoke — confirm a finalist with one needsVisa attendee, open Visas tab, edit → verify persistence.

  • Step 5: Commit.


Task 6: Member visa surface on AttendingMembersCard

Files:

  • Modify: src/components/applicant/attending-members-card.tsx

  • Step 1: Wire the query

Call applicant.getMyVisaApplications alongside the existing confirmation query. If the result is null (toggle off), don't render any visa info. Otherwise, render a small status badge + the next upcoming date next to each attendee whose needsVisa=true.

  • Step 2: Live smoke — toggle visibility off in the admin tab, confirm member can no longer see status.

  • Step 3: Commit.


Task 7: Final verification

  • Step 1: Full vitestnpx vitest run. Expect 148 + new tests, all green.
  • Step 2: Typechecknpm run typecheck.
  • Step 3: Buildnpm run build.
  • Step 4: Live smoke end-to-end — confirm an attendee with visa, edit status as admin, verify member view, toggle off, verify hidden.