From 289903c8bd4c2eed7898a40b76374b5ca1b5b03c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 19:28:53 +0200 Subject: [PATCH] feat: schema for visa tracking (additive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-04-28-pr4-visa-tracking.md | 269 ++++++++++++++++++ .../migration.sql | 30 ++ prisma/schema.prisma | 43 ++- 3 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md create mode 100644 prisma/migrations/20260428172835_add_visa_tracking/migration.sql diff --git a/docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md b/docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md new file mode 100644 index 0000000..70a9fa8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-pr4-visa-tracking.md @@ -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/_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. diff --git a/prisma/migrations/20260428172835_add_visa_tracking/migration.sql b/prisma/migrations/20260428172835_add_visa_tracking/migration.sql new file mode 100644 index 0000000..1f0a133 --- /dev/null +++ b/prisma/migrations/20260428172835_add_visa_tracking/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17bd691..bfd3561 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +}