# 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/_add_visa_tracking/migration.sql` - [ ] **Step 1: Add the enum + model + program toggle** ```prisma 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`: ```prisma visaApplication VisaApplication? ``` Add to `Program`: ```prisma 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** ```ts 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`: ```ts // 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** ```ts 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** ```ts 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 ``. - [ ] **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 vitest** — `npx vitest run`. Expect 148 + new tests, all green. - [ ] **Step 2: Typecheck** — `npm run typecheck`. - [ ] **Step 3: Build** — `npm 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.