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>
This commit is contained in:
269
docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md
Normal file
269
docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 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**
|
||||
|
||||
```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 `<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 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.
|
||||
@@ -0,0 +1,30 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VisaStatus" AS ENUM ('NOT_NEEDED', 'REQUESTED', 'INVITATION_SENT', 'APPOINTMENT_BOOKED', 'GRANTED', 'DENIED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Program" ADD COLUMN "visaStatusVisibleToMembers" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VisaApplication" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attendingMemberId" TEXT NOT NULL,
|
||||
"status" "VisaStatus" NOT NULL DEFAULT 'REQUESTED',
|
||||
"nationality" TEXT,
|
||||
"invitationSentAt" TIMESTAMP(3),
|
||||
"appointmentAt" TIMESTAMP(3),
|
||||
"decisionAt" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VisaApplication_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VisaApplication_attendingMemberId_key" ON "VisaApplication"("attendingMemberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VisaApplication_status_idx" ON "VisaApplication"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VisaApplication" ADD CONSTRAINT "VisaApplication_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -487,7 +487,8 @@ model Program {
|
||||
settingsJson Json? @db.JsonB
|
||||
|
||||
// Grand-finale logistics
|
||||
defaultAttendeeCap Int @default(3) // Max team members allowed at the grand finale per finalist team
|
||||
defaultAttendeeCap Int @default(3) // Max team members allowed at the grand finale per finalist team
|
||||
visaStatusVisibleToMembers Boolean @default(true) // Whether team members see their own visa status on the applicant dashboard
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2726,9 +2727,10 @@ model AttendingMember {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
flightDetail FlightDetail?
|
||||
confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
flightDetail FlightDetail?
|
||||
visaApplication VisaApplication?
|
||||
|
||||
@@unique([confirmationId, userId])
|
||||
@@index([userId])
|
||||
@@ -2774,3 +2776,36 @@ model FlightDetail {
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Grand-finale visa tracking (PR 4)
|
||||
// Process metadata only — no document storage. Passport scans / invitation
|
||||
// letters / decision documents are exchanged over email; this model just
|
||||
// records what stage the application is at, key dates, and free-text notes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum VisaStatus {
|
||||
NOT_NEEDED
|
||||
REQUESTED
|
||||
INVITATION_SENT
|
||||
APPOINTMENT_BOOKED
|
||||
GRANTED
|
||||
DENIED
|
||||
}
|
||||
|
||||
model VisaApplication {
|
||||
id String @id @default(cuid())
|
||||
attendingMemberId String @unique // 1:1
|
||||
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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user